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, see <https://www.gnu.org/licenses/>.
18 */
19
20#include <local_history.h>
22#include <history_lock.h>
23#include <paths.h>
25#include <lockfile.h>
28#include <pgm_base.h>
29#include <thread_pool.h>
30#include <trace_helpers.h>
32#include <confirm.h>
33#include <progress_reporter.h>
34
35#include <kiplatform/io.h>
36
37#include <git2.h>
38#include <wx/filename.h>
39#include <wx/filefn.h>
40#include <wx/ffile.h>
41#include <wx/dir.h>
42#include <wx/datetime.h>
43#include <wx/log.h>
44#include <wx/msgdlg.h>
45
46#include <vector>
47#include <string>
48#include <memory>
49#include <algorithm>
50#include <set>
51#include <map>
52#include <functional>
53#include <cstring>
54
55// Resolve the local-history storage directory for @p aProjectPath, honoring the
56static wxString historyPath( const wxString& aProjectPath )
57{
58 return Pgm().GetSettingsManager().GetLocalHistoryDirForPath( aProjectPath );
59}
60
61
62// Join a saver-supplied relative path with the on-disk storage root for the
63// active backup format and location. Forward slashes in @p aRelativePath are
64// preserved so libgit2 paths remain platform-neutral.
65static wxString joinHistoryDestination( const wxString& aHistoryRoot,
66 const wxString& aRelativePath )
67{
68 wxFileName fn( aRelativePath );
69
70 if( fn.IsAbsolute() )
71 return fn.GetFullPath(); // Defensive: should not happen with the new contract.
72
73 // Prepend the history root while preserving any subdirectories supplied by the saver
74 // (e.g. hierarchical sheet "sub/sheet.kicad_sch" must land at
75 // "<root>/sub/sheet.kicad_sch", not "<root>/sheet.kicad_sch").
76 wxArrayString dirs = fn.GetDirs();
77
78 wxFileName dst;
79 dst.AssignDir( aHistoryRoot );
80
81 for( const wxString& d : dirs )
82 dst.AppendDir( d );
83
84 dst.SetFullName( fn.GetFullName() );
85 return dst.GetFullPath();
86}
87
88
89static const wxString AUTOSAVE_PREFIX = wxS( "_autosave-" );
90
91
92// Compare two files byte-for-byte.
93static bool filesContentEqual( const wxString& aPathA, const wxString& aPathB )
94{
95 wxFFile fileA( aPathA, wxS( "rb" ) );
96 wxFFile fileB( aPathB, wxS( "rb" ) );
97
98 if( !fileA.IsOpened() || !fileB.IsOpened() )
99 return false;
100
101 wxFileOffset lenA = fileA.Length();
102 wxFileOffset lenB = fileB.Length();
103
104 if( lenA < 0 || lenB < 0 || lenA != lenB )
105 return false;
106
107 constexpr size_t chunkSize = 64 * 1024;
108 std::vector<char> bufA( chunkSize );
109 std::vector<char> bufB( chunkSize );
110
111 while( !fileA.Eof() )
112 {
113 size_t readA = fileA.Read( bufA.data(), chunkSize );
114 size_t readB = fileB.Read( bufB.data(), chunkSize );
115
116 if( readA != readB )
117 return false;
118
119 if( readA > 0 && std::memcmp( bufA.data(), bufB.data(), readA ) != 0 )
120 return false;
121
122 if( fileA.Error() || fileB.Error() )
123 return false;
124 }
125
126 return true;
127}
128
129
130// Resolve the autosave-file destination for a given relative path. In PROJECT_DIR
131// mode the file lives next to the original (or under the same subdir for nested
132// schematic sheets) with an "_autosave-" prefix on the basename. In USER_DIR mode
133// the file mirrors the project tree under the user data root with no name munging
134// -- the per-project hash subdirectory already isolates autosave content.
135static wxString resolveAutosaveDestination( const wxString& aAutosaveRoot,
136 const wxString& aRelativePath,
137 BACKUP_LOCATION aLocation )
138{
139 wxFileName rel( aRelativePath );
140 wxFileName dst;
141 dst.AssignDir( aAutosaveRoot );
142
143 for( const wxString& d : rel.GetDirs() )
144 dst.AppendDir( d );
145
146 if( aLocation == BACKUP_LOCATION::PROJECT_DIR )
147 dst.SetFullName( AUTOSAVE_PREFIX + rel.GetFullName() );
148 else
149 dst.SetFullName( rel.GetFullName() );
150
151 return dst.GetFullPath();
152}
153
154
155// Compute the source-file path that an autosave destination corresponds to.
156// In PROJECT_DIR mode the source is the same directory minus the "_autosave-"
157// prefix. In USER_DIR mode the source is the original under the project tree.
158static wxString sourceForAutosaveFile( const wxString& aAutosavePath,
159 const wxString& aProjectPath,
160 const wxString& aAutosaveRoot,
161 BACKUP_LOCATION aLocation )
162{
163 wxFileName autosave( aAutosavePath );
164
165 if( aLocation == BACKUP_LOCATION::PROJECT_DIR )
166 {
167 wxString name = autosave.GetFullName();
168
169 if( !name.StartsWith( AUTOSAVE_PREFIX ) )
170 return wxEmptyString;
171
172 autosave.SetFullName( name.Mid( AUTOSAVE_PREFIX.length() ) );
173 return autosave.GetFullPath();
174 }
175
176 if( !aAutosavePath.StartsWith( aAutosaveRoot ) )
177 return wxEmptyString;
178
179 wxString rel = aAutosavePath.Mid( aAutosaveRoot.length() );
180 wxFileName projFn( aProjectPath, wxEmptyString );
181
182 return projFn.GetPathWithSep() + rel;
183}
184
185
186static bool commitSnapshotForProject( const wxString& aProjectPath, const std::vector<wxString>& aFiles,
187 const wxString& aTitle );
188
189
190// Single point of control: incremental git history is active only when the user
191// has selected BACKUP_FORMAT::INCREMENTAL. In zip mode we leave any pre-existing
192// .history dormant on disk and skip all write/commit operations so we do not
193// keep extending a history the user has switched off. Read-only paths
194// (HistoryExists, RestoreCommit, ShowRestoreDialog) intentionally bypass this
195// gate so users can still browse dormant history after switching back.
200
201
202// Local history is project-scoped. When pcbnew or eeschema is launched
203// standalone without a project, save paths can land anywhere on the
204// filesystem (e.g. /tmp), and walking those directories to feed libgit2
205// would be catastrophic.
206static bool isProjectDirectory( const wxString& aProjectPath )
207{
208 if( aProjectPath.IsEmpty() || !wxDirExists( aProjectPath ) )
209 return false;
210
211 wxDir dir( aProjectPath );
212 wxString name;
213
214 return dir.IsOpened()
215 && dir.GetFirst( &name, wxString( wxS( "*." ) ) + FILEEXT::ProjectFileExtension, wxDIR_FILES );
216}
217
218
219// Top-level project entries that must survive a restore unchanged: git/history metadata, the
220// transient restore staging directories (current and any timestamped retained copies), and
221// the per-project zip backup directory produced by SETTINGS_MANAGER::BackupProject (named
222// "<projectname>-backups").
223static bool isRestoreProtectedEntry( const wxString& aName )
224{
225 return aName == wxS( ".history" )
226 || aName == wxS( ".git" )
227 || aName == wxS( "_restore_backup" )
228 || aName.StartsWith( wxS( "_restore_backup_" ) )
229 || aName == wxS( "_restore_temp" )
230 || aName.EndsWith( PROJECT_BACKUPS_DIR_SUFFIX );
231}
232
236
241
242void LOCAL_HISTORY::NoteFileChange( const wxString& aFile )
243{
244 wxFileName fn( aFile );
245
246 if( fn.GetFullName() == wxS( "fp-info-cache" ) || !Pgm().GetCommonSettings()->m_Backup.enabled )
247 return;
248
249 m_pendingFiles.insert( fn.GetFullPath() );
250}
251
252
254 const void* aSaverObject,
255 const std::function<void( const wxString&, std::vector<HISTORY_FILE_DATA>& )>& aSaver )
256{
257 if( m_savers.find( aSaverObject ) != m_savers.end() )
258 {
259 wxLogTrace( traceAutoSave, wxS( "[history] Saver %p already registered, skipping" ), aSaverObject );
260 return;
261 }
262
263 m_savers[aSaverObject] = aSaver;
264 wxLogTrace( traceAutoSave, wxS( "[history] Registered saver %p (total=%zu)" ), aSaverObject, m_savers.size() );
265}
266
267
268void LOCAL_HISTORY::UnregisterSaver( const void* aSaverObject )
269{
271
272 auto it = m_savers.find( aSaverObject );
273
274 if( it != m_savers.end() )
275 {
276 m_savers.erase( it );
277 wxLogTrace( traceAutoSave, wxS( "[history] Unregistered saver %p (total=%zu)" ),
278 aSaverObject, m_savers.size() );
279 }
280}
281
282
284{
286 m_savers.clear();
287 wxLogTrace( traceAutoSave, wxS( "[history] Cleared all savers" ) );
288}
289
290
291bool LOCAL_HISTORY::RunRegisteredSaversAndCommit( const wxString& aProjectPath, const wxString& aTitle,
292 const wxString& aTagFileType )
293{
294 if( !Pgm().GetCommonSettings()->m_Backup.enabled )
295 {
296 wxLogTrace( traceAutoSave, wxS( "Autosave disabled, returning" ) );
297 return true;
298 }
299
301 {
302 wxLogTrace( traceAutoSave, wxS( "[history] Backup format is ZIP; skipping git commit" ) );
303 return true;
304 }
305
306 if( !isProjectDirectory( aProjectPath ) )
307 return false;
308
309 Init( aProjectPath );
310
311 wxLogTrace( traceAutoSave,
312 wxS( "[history] RunRegisteredSaversAndCommit start project='%s' title='%s' savers=%zu tag='%s'" ),
313 aProjectPath, aTitle, m_savers.size(), aTagFileType );
314
315 if( m_savers.empty() )
316 {
317 wxLogTrace( traceAutoSave, wxS( "[history] no savers registered; skipping") );
318 return false;
319 }
320
321 // Manual save must land; autosave is droppable because another tick will retry.
322 if( !aTagFileType.IsEmpty() )
323 {
325 }
326 else if( m_saveInProgress.load( std::memory_order_acquire ) )
327 {
328 wxLogTrace( traceAutoSave, wxS( "[history] previous save still in progress; skipping cycle" ) );
329 return false;
330 }
331
332 // Phase 1 (UI thread): call savers to collect serialized data
333 std::vector<HISTORY_FILE_DATA> fileData;
334
335 for( const auto& [saverObject, saver] : m_savers )
336 {
337 size_t before = fileData.size();
338 saver( aProjectPath, fileData );
339 wxLogTrace( traceAutoSave, wxS( "[history] saver %p produced %zu entries (total=%zu)" ),
340 saverObject, fileData.size() - before, fileData.size() );
341 }
342
343 // Reject entries with an empty or absolute relativePath; the saver contract requires
344 // a project-relative path so we can dispatch to either the .history mirror or the
345 // autosave-files root without ambiguity.
346 fileData.erase( std::remove_if( fileData.begin(), fileData.end(),
347 []( const HISTORY_FILE_DATA& entry )
348 {
349 if( entry.relativePath.IsEmpty() || wxFileName( entry.relativePath ).IsAbsolute() )
350 {
351 wxLogTrace( traceAutoSave, wxS( "[history] filtered out entry with invalid path: '%s'" ),
352 entry.relativePath );
353 return true;
354 }
355 return false;
356 } ),
357 fileData.end() );
358
359 if( fileData.empty() )
360 {
361 wxLogTrace( traceAutoSave, wxS( "[history] saver set produced no entries; skipping" ) );
362 return false;
363 }
364
365 // Phase 2: submit Prettify + file I/O + git to background thread
366 m_saveInProgress.store( true, std::memory_order_release );
367
368 m_pendingFuture = GetKiCadThreadPool().submit_task(
369 [this, projectPath = aProjectPath, title = aTitle, tagFileType = aTagFileType,
370 data = std::move( fileData )]() mutable -> bool
371 {
372 bool result = commitInBackground( projectPath, title, data, !tagFileType.IsEmpty() );
373
374 if( !tagFileType.IsEmpty() )
375 TagSave( projectPath, tagFileType );
376
377 m_saveInProgress.store( false, std::memory_order_release );
378 return result;
379 } );
380
381 // Manual save must complete (commit + tag)
382 if( !aTagFileType.IsEmpty() )
383 WaitForPendingSave();
384
385 return true;
386}
387
388
389bool LOCAL_HISTORY::RunRegisteredSaversAsAutosaveFiles( const wxString& aProjectPath )
390{
391 if( !Pgm().GetCommonSettings()->m_Backup.enabled )
392 return true;
393
394 if( m_savers.empty() )
395 {
396 wxLogTrace( traceAutoSave, wxS( "[autosave] no savers registered; skipping" ) );
397 return false;
398 }
399
402 wxString autosaveRoot = mgr.GetAutosaveRootForProject( mgr.GetProjectForPath( aProjectPath ) );
403
404 if( !PATHS::EnsurePathExists( autosaveRoot ) )
405 {
406 wxLogTrace( traceAutoSave, wxS( "[autosave] cannot create autosave root '%s'" ), autosaveRoot );
407 return false;
408 }
409
410 std::vector<HISTORY_FILE_DATA> fileData;
411
412 for( const auto& [saverObject, saver] : m_savers )
413 saver( aProjectPath, fileData );
414
415 bool anyWritten = false;
416
417 for( HISTORY_FILE_DATA& entry : fileData )
418 {
419 if( entry.relativePath.IsEmpty() || wxFileName( entry.relativePath ).IsAbsolute() )
420 continue;
421
422 wxString dst = resolveAutosaveDestination( autosaveRoot, entry.relativePath, location );
423 wxFileName dstFn( dst );
424
425 if( !PATHS::EnsurePathExists( dstFn.GetPath() ) )
426 {
427 wxLogTrace( traceAutoSave, wxS( "[autosave] cannot create dir '%s'" ), dstFn.GetPath() );
428 continue;
429 }
430
431 std::string buf;
432
433 if( !entry.content.empty() )
434 {
435 buf = std::move( entry.content );
436
437 if( entry.prettify )
438 KICAD_FORMAT::Prettify( buf, entry.formatMode );
439 }
440 else if( !entry.sourcePath.IsEmpty() )
441 {
442 wxFFile src( entry.sourcePath, wxS( "rb" ) );
443
444 if( !src.IsOpened() )
445 continue;
446
447 wxFileOffset len = src.Length();
448
449 if( len < 0 )
450 continue;
451
452 buf.resize( static_cast<size_t>( len ) );
453
454 if( len > 0 && src.Read( buf.data(), buf.size() ) != buf.size() )
455 {
456 buf.clear();
457 continue;
458 }
459 }
460 else
461 {
462 continue;
463 }
464
465 wxString err;
466
467 if( KIPLATFORM::IO::AtomicWriteFile( dst, buf.data(), buf.size(), &err ) )
468 {
469 anyWritten = true;
470 wxLogTrace( traceAutoSave, wxS( "[autosave] wrote %zu bytes to '%s'" ), buf.size(), dst );
471 }
472 else
473 {
474 wxLogTrace( traceAutoSave, wxS( "[autosave] write failed for '%s': %s" ), dst, err );
475 }
476 }
477
478 return anyWritten;
479}
480
481
482// Enumerate every (autosave, source) pair under the per-project autosave root, without
483// any modification-time filter. Callers that want only files newer than their source
484// (the recovery-prompt path) apply that filter themselves; cleanup callers want the
485// full list so they can remove leftover autosave files even when the source has been
486// re-saved and is newer.
487static std::vector<std::pair<wxString, wxString>>
488findAutosaveFilePairs( const wxString& aProjectPath )
489{
490 std::vector<std::pair<wxString, wxString>> results;
491
494 wxString autosaveRoot = mgr.GetAutosaveRootForProject( mgr.GetProjectForPath( aProjectPath ) );
495
496 if( !wxDirExists( autosaveRoot ) )
497 return results;
498
499 std::function<void( const wxString& )> walk = [&]( const wxString& aDir )
500 {
501 wxDir d( aDir );
502
503 if( !d.IsOpened() )
504 return;
505
506 wxString name;
507 bool cont = d.GetFirst( &name );
508
509 while( cont )
510 {
511 wxFileName fn( aDir, name );
512 wxString fullPath = fn.GetFullPath();
513
514 if( wxDirExists( fullPath ) )
515 {
517 && ( name == wxS( ".history" ) || name.EndsWith( wxS( "-backups" ) ) ) )
518 {
519 cont = d.GetNext( &name );
520 continue;
521 }
522
523 walk( fullPath );
524 }
526 || fn.GetFullName().StartsWith( AUTOSAVE_PREFIX ) )
527 {
528 wxString src = sourceForAutosaveFile( fullPath, aProjectPath, autosaveRoot,
529 location );
530
531 if( !src.IsEmpty() )
532 results.emplace_back( fullPath, src );
533 }
534
535 cont = d.GetNext( &name );
536 }
537 };
538
539 walk( autosaveRoot );
540 return results;
541}
542
543
544std::vector<std::pair<wxString, wxString>>
545LOCAL_HISTORY::FindStaleAutosaveFiles( const wxString& aProjectPath, const std::vector<wxString>& aExtensions ) const
546{
547 std::vector<std::pair<wxString, wxString>> results;
548
549 if( aExtensions.empty() )
550 return results;
551
552 for( auto& pair : findAutosaveFilePairs( aProjectPath ) )
553 {
554 wxFileName srcFn( pair.second );
555 bool match = false;
556
557 for( const wxString& ext : aExtensions )
558 {
559 if( srcFn.GetExt().IsSameAs( ext, false ) )
560 {
561 match = true;
562 break;
563 }
564 }
565
566 if( !match )
567 continue;
568
569 wxDateTime srcTime;
570
571 if( srcFn.FileExists() )
572 srcTime = srcFn.GetModificationTime();
573
574 wxDateTime autosaveTime = wxFileName( pair.first ).GetModificationTime();
575
576 // mtime is only a pre-filter; cloud-sync clients bump the byte-identical autosave's
577 // mtime past the source, so confirm the content actually diverges (issue 24126).
578 bool stale = !srcTime.IsValid()
579 || ( autosaveTime.IsLaterThan( srcTime )
580 && !filesContentEqual( pair.first, pair.second ) );
581
582 if( stale )
583 results.emplace_back( std::move( pair ) );
584 }
585
586 return results;
587}
588
589
590void LOCAL_HISTORY::RemoveAutosaveFiles( const wxString& aProjectPath ) const
591{
592 // After a successful manual save the source typically has a newer mtime than its
593 // autosave, so we cannot rely on FindStaleAutosaveFiles() here -- we need to remove
594 // every autosave file associated with the project regardless of mtime.
595 for( const auto& [autosavePath, srcPath] : findAutosaveFilePairs( aProjectPath ) )
596 {
597 if( wxFileExists( autosavePath ) )
598 wxRemoveFile( autosavePath );
599 }
600}
601
602
603void LOCAL_HISTORY::RemoveAutosaveFiles( const wxString& aProjectPath,
604 const std::vector<wxString>& aSourcePaths ) const
605{
606 if( aSourcePaths.empty() )
607 return;
608
609 std::vector<wxFileName> targets;
610 targets.reserve( aSourcePaths.size() );
611
612 for( const wxString& src : aSourcePaths )
613 {
614 if( !src.IsEmpty() )
615 targets.emplace_back( src );
616 }
617
618 if( targets.empty() )
619 return;
620
621 for( const auto& [autosavePath, srcPath] : findAutosaveFilePairs( aProjectPath ) )
622 {
623 wxFileName srcFn( srcPath );
624 bool match = false;
625
626 for( const wxFileName& target : targets )
627 {
628 if( srcFn.SameAs( target ) )
629 {
630 match = true;
631 break;
632 }
633 }
634
635 if( match && wxFileExists( autosavePath ) )
636 wxRemoveFile( autosavePath );
637 }
638}
639
640
641bool LOCAL_HISTORY::commitInBackground( const wxString& aProjectPath, const wxString& aTitle,
642 const std::vector<HISTORY_FILE_DATA>& aFileData, bool aIsManualSave )
643{
644 wxLogTrace( traceAutoSave, wxS( "[history] background: writing %zu entries for '%s'" ),
645 aFileData.size(), aProjectPath );
646
647 wxString hist = historyPath( aProjectPath );
648
649 if( !PATHS::EnsurePathExists( hist ) )
650 {
651 wxLogTrace( traceAutoSave, wxS( "[history] background: cannot create history root '%s'" ), hist );
652 return false;
653 }
654
655 for( const HISTORY_FILE_DATA& entry : aFileData )
656 {
657 wxString dst = joinHistoryDestination( hist, entry.relativePath );
658 wxFileName dstFn( dst );
659 wxString parent = dstFn.GetPath();
660
661 if( !parent.IsEmpty() && !PATHS::EnsurePathExists( parent ) )
662 {
663 wxLogTrace( traceAutoSave, wxS( "[history] background: cannot create dir '%s'" ), parent );
664 continue;
665 }
666
667 if( !entry.content.empty() )
668 {
669 std::string buf = entry.content;
670
671 if( entry.prettify )
672 KICAD_FORMAT::Prettify( buf, entry.formatMode );
673
674 wxFFile fp( dst, wxS( "wb" ) );
675
676 if( fp.IsOpened() )
677 {
678 fp.Write( buf.data(), buf.size() );
679 fp.Close();
680 wxLogTrace( traceAutoSave, wxS( "[history] background: wrote %zu bytes to '%s'" ), buf.size(), dst );
681 }
682 else
683 {
684 wxLogTrace( traceAutoSave, wxS( "[history] background: failed to open '%s' for writing" ), dst );
685 }
686 }
687 else if( !entry.sourcePath.IsEmpty() )
688 {
689 wxCopyFile( entry.sourcePath, dst, true );
690 wxLogTrace( traceAutoSave, wxS( "[history] background: copied '%s' -> '%s'" ), entry.sourcePath, dst );
691 }
692 }
693
694 // Acquire locks using hybrid locking strategy
695 HISTORY_LOCK_MANAGER lock( aProjectPath );
696
697 if( !lock.IsLocked() )
698 {
699 wxLogTrace( traceAutoSave, wxS( "[history] background: failed to acquire lock: %s" ), lock.GetLockError() );
700 return false;
701 }
702
703 git_repository* repo = lock.GetRepository();
704 git_index* index = lock.GetIndex();
705
706 git_repository_set_workdir( repo, hist.mb_str().data(), false );
707
708 // Stage all written files using their project-relative paths. libgit2 needs forward
709 // slashes on every platform, so normalize before adding to the index.
710 for( const HISTORY_FILE_DATA& entry : aFileData )
711 {
712 wxString rel = entry.relativePath;
713 rel.Replace( wxS( "\\" ), wxS( "/" ) );
714
715 wxString abs = joinHistoryDestination( hist, entry.relativePath );
716
717 if( !wxFileExists( abs ) )
718 continue;
719
720 git_index_add_bypath( index, rel.ToStdString().c_str() );
721 }
722
723 // Compare index to HEAD; if no diff -> abort to avoid empty commit.
724 git_oid head_oid;
725 git_commit* head_commit = nullptr;
726 git_tree* head_tree = nullptr;
727
728 bool headExists = ( git_reference_name_to_id( &head_oid, repo, "HEAD" ) == 0 )
729 && ( git_commit_lookup( &head_commit, repo, &head_oid ) == 0 )
730 && ( git_commit_tree( &head_tree, head_commit ) == 0 );
731
732 git_tree* rawIndexTree = nullptr;
733 git_oid index_tree_oid;
734
735 if( git_index_write_tree( &index_tree_oid, index ) != 0 )
736 {
737 if( head_tree )
738 git_tree_free( head_tree );
739
740 if( head_commit )
741 git_commit_free( head_commit );
742
743 wxLogTrace( traceAutoSave, wxS("[history] background: failed to write index tree" ) );
744 return false;
745 }
746
747 git_tree_lookup( &rawIndexTree, repo, &index_tree_oid );
748 std::unique_ptr<git_tree, decltype( &git_tree_free )> indexTree( rawIndexTree, &git_tree_free );
749
750 bool hasChanges = true;
751
752 if( headExists )
753 {
754 git_diff* diff = nullptr;
755
756 if( git_diff_tree_to_tree( &diff, repo, head_tree, indexTree.get(), nullptr ) == 0 )
757 {
758 hasChanges = git_diff_num_deltas( diff ) > 0;
759 wxLogTrace( traceAutoSave, wxS( "[history] background: diff deltas=%u" ),
760 (unsigned) git_diff_num_deltas( diff ) );
761 git_diff_free( diff );
762 }
763 }
764 else
765 {
766 // No HEAD: skip commit if staged matches disk, so an idle autosave on a fresh
767 // project doesn't leave an untagged HEAD that triggers a no-op restore prompt.
768 bool stagedMatchesDisk = true;
769
770 for( const HISTORY_FILE_DATA& entry : aFileData )
771 {
772 wxString diskPath = aProjectPath + wxFileName::GetPathSeparator() + entry.relativePath;
773 wxString histPath = joinHistoryDestination( hist, entry.relativePath );
774
775 if( !wxFileExists( diskPath ) || !wxFileExists( histPath ) )
776 {
777 stagedMatchesDisk = false;
778 break;
779 }
780
781 wxFFile diskFile( diskPath, wxT( "rb" ) );
782 wxFFile histFile( histPath, wxT( "rb" ) );
783
784 if( !diskFile.IsOpened() || !histFile.IsOpened() || diskFile.Length() != histFile.Length() )
785 {
786 stagedMatchesDisk = false;
787 break;
788 }
789
790 size_t len = static_cast<size_t>( diskFile.Length() );
791 std::string diskBuf( len, '\0' );
792 std::string histBuf( len, '\0' );
793
794 if( diskFile.Read( diskBuf.data(), len ) != len
795 || histFile.Read( histBuf.data(), len ) != len
796 || diskBuf != histBuf )
797 {
798 stagedMatchesDisk = false;
799 break;
800 }
801 }
802
803 if( stagedMatchesDisk && !aIsManualSave )
804 {
805 wxLogTrace( traceAutoSave, wxS( "[history] background: first commit; staged matches disk -- skipping" ) );
806 hasChanges = false;
807 }
808 }
809
810 if( head_tree )
811 git_tree_free( head_tree );
812
813 if( head_commit )
814 git_commit_free( head_commit );
815
816 if( !hasChanges )
817 {
818 wxLogTrace( traceAutoSave, wxS("[history] background: no changes detected; no commit") );
819
820 // Manual save matching HEAD: amend the prior message so the user's explicit save
821 // shows in the history dialog. Skip if HEAD already has this title.
822 if( !aTitle.IsEmpty() && aTitle != wxS( "Autosave" ) )
823 {
824 git_oid head_oid_amend;
825
826 if( git_reference_name_to_id( &head_oid_amend, repo, "HEAD" ) == 0 )
827 {
828 git_commit* head_commit_amend = nullptr;
829
830 if( git_commit_lookup( &head_commit_amend, repo, &head_oid_amend ) == 0 )
831 {
832 wxString existingMsg = wxString::FromUTF8( git_commit_message( head_commit_amend ) );
833 existingMsg.Trim( true ).Trim( false );
834
835 if( existingMsg != aTitle )
836 {
837 git_oid amended_oid;
838 int amend_rc = git_commit_amend( &amended_oid, head_commit_amend, "HEAD", nullptr, nullptr,
839 nullptr, aTitle.mb_str().data(), nullptr );
840
841 if( amend_rc == 0 )
842 wxLogTrace( traceAutoSave, wxS( "[history] background: amended HEAD message '%s' -> '%s'" ),
843 existingMsg, aTitle );
844 else
845 wxLogTrace( traceAutoSave, wxS( "[history] background: amend failed rc=%d" ), amend_rc );
846 }
847
848 git_commit_free( head_commit_amend );
849 }
850 }
851 }
852
853 return false; // Nothing new; skip commit.
854 }
855
856 git_signature* rawSig = nullptr;
857 git_signature_now( &rawSig, "KiCad", "[email protected]" );
858 std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( rawSig, &git_signature_free );
859
860 git_commit* parent = nullptr;
861 git_oid parent_id;
862 int parents = 0;
863
864 if( git_reference_name_to_id( &parent_id, repo, "HEAD" ) == 0 )
865 {
866 if( git_commit_lookup( &parent, repo, &parent_id ) == 0 )
867 parents = 1;
868 }
869
870 wxString msg = aTitle.IsEmpty() ? wxString( "Autosave" ) : aTitle;
871 git_oid commit_id;
872 const git_commit* constParent = parent;
873
874 int rc = git_commit_create( &commit_id, repo, "HEAD", sig.get(), sig.get(), nullptr,
875 msg.mb_str().data(), indexTree.get(), parents,
876 parents ? &constParent : nullptr );
877
878 if( rc == 0 )
879 {
880 wxLogTrace( traceAutoSave, wxS( "[history] background: commit created %s (%s entries=%zu)" ),
881 wxString::FromUTF8( git_oid_tostr_s( &commit_id ) ), msg, aFileData.size() );
882 }
883 else
884 {
885 wxLogTrace( traceAutoSave, wxS( "[history] background: commit failed rc=%d" ), rc );
886 }
887
888 if( parent )
889 git_commit_free( parent );
890
891 git_index_write( index );
892 return rc == 0;
893}
894
895
897{
898 if( m_pendingFuture.valid() )
899 {
900 wxLogTrace( traceAutoSave, wxS( "[history] waiting for pending background save" ) );
901 m_pendingFuture.get();
902 }
903}
904
905
907{
908 std::vector<wxString> files( m_pendingFiles.begin(), m_pendingFiles.end() );
909 m_pendingFiles.clear();
910 return CommitSnapshot( files, wxS( "Autosave" ) );
911}
912
913
914bool LOCAL_HISTORY::Init( const wxString& aProjectPath )
915{
916 if( !isProjectDirectory( aProjectPath ) )
917 return false;
918
919 if( !Pgm().GetCommonSettings()->m_Backup.enabled || !formatUsesIncrementalHistory() )
920 return true;
921
922 wxString hist = historyPath( aProjectPath );
923
924 if( !wxDirExists( hist ) )
925 {
926 // EnsurePathExists creates intermediate directories as needed, which is required
927 // for USER_DIR mode where the parent (e.g., ~/.config/kicad/<ver>/local_history/)
928 // may not yet exist. In PROJECT_DIR mode it falls back to a single mkdir.
929 if( !PATHS::EnsurePathExists( hist ) )
930 return false;
931 }
932
933 git_repository* rawRepo = nullptr;
934
935 if( git_repository_open( &rawRepo, hist.mb_str().data() ) != 0 )
936 {
937 if( git_repository_init( &rawRepo, hist.mb_str().data(), 0 ) != 0 )
938 return false;
939
940 wxFileName ignoreFile( hist, wxS( ".gitignore" ) );
941 if( !ignoreFile.FileExists() )
942 {
943 wxFFile f( ignoreFile.GetFullPath(), wxT( "w" ) );
944 if( f.IsOpened() )
945 {
946 f.Write( wxS( "# KiCad local history exclusions. Edit to add your own rules.\n"
947 "fp-info-cache\n"
948 "*-backups/\n" ) );
949 f.Close();
950 }
951 }
952
953 wxFileName readmeFile( hist, wxS( "README.txt" ) );
954
955 if( !readmeFile.FileExists() )
956 {
957 wxFFile f( readmeFile.GetFullPath(), wxT( "w" ) );
958
959 if( f.IsOpened() )
960 {
961 f.Write( wxS( "KiCad Local History Directory\n"
962 "=============================\n\n"
963 "This directory contains automatic snapshots of your project files.\n"
964 "KiCad periodically saves copies of your work here, allowing you to\n"
965 "recover from accidental changes or data loss.\n\n"
966 "You can browse and restore previous versions through KiCad's\n"
967 "File > Local History menu.\n\n"
968 "To disable this feature:\n"
969 " Preferences > Common > Project Backup > Enable automatic backups\n\n"
970 "This directory can be safely deleted if you no longer need the\n"
971 "history, but doing so will permanently remove all saved snapshots.\n" ) );
972 f.Close();
973 }
974 }
975 }
976
977 git_repository_free( rawRepo );
978
979 return true;
980}
981
982
983// Helper function to commit files using an already-acquired lock
990
991
992static SNAPSHOT_COMMIT_RESULT commitSnapshotWithLock( git_repository* repo, git_index* index,
993 const wxString& aHistoryPath, const wxString& aProjectPath,
994 const std::vector<wxString>& aFiles, const wxString& aTitle )
995{
996 std::vector<std::string> filesArrStr;
997
998 for( const wxString& file : aFiles )
999 {
1000 wxFileName src( file );
1001 wxString relPath;
1002
1003 if( src.GetFullPath().StartsWith( aProjectPath + wxFILE_SEP_PATH ) )
1004 relPath = src.GetFullPath().Mid( aProjectPath.length() + 1 );
1005 else
1006 relPath = src.GetFullName(); // Fallback (should not normally happen)
1007
1008 relPath.Replace( "\\", "/" ); // libgit2 needs forward slashes on all platforms
1009 std::string relPathStr = relPath.ToStdString();
1010
1011 unsigned int status = 0;
1012 int rc = git_status_file( &status, repo, relPathStr.data() );
1013
1014 if( rc == 0 && status != 0 )
1015 {
1016 wxLogTrace( traceAutoSave, wxS( "File %s status %d " ), relPath, status );
1017 filesArrStr.emplace_back( relPathStr );
1018 }
1019 else if( rc != 0 )
1020 {
1021 wxLogTrace( traceAutoSave, wxS( "File %s status error %d " ), relPath, rc );
1022 filesArrStr.emplace_back( relPathStr ); // Add anyway even if the file is untracked.
1023 }
1024 }
1025
1026 std::vector<char*> cStrings( filesArrStr.size() );
1027
1028 for( size_t i = 0; i < filesArrStr.size(); i++ )
1029 cStrings[i] = filesArrStr[i].data();
1030
1031 git_strarray filesArrGit;
1032 filesArrGit.count = filesArrStr.size();
1033 filesArrGit.strings = cStrings.data();
1034
1035 if( filesArrStr.size() == 0 )
1036 {
1037 wxLogTrace( traceAutoSave, wxS( "No changes, skipping" ) );
1039 }
1040
1041 int rc = git_index_add_all( index, &filesArrGit, GIT_INDEX_ADD_DISABLE_PATHSPEC_MATCH | GIT_INDEX_ADD_FORCE, NULL,
1042 NULL );
1043 wxLogTrace( traceAutoSave, wxS( "Adding %zu files, rc %d" ), filesArrStr.size(), rc );
1044
1045 if( rc != 0 )
1047
1048 git_oid tree_id;
1049 if( git_index_write_tree( &tree_id, index ) != 0 )
1051
1052 git_tree* rawTree = nullptr;
1053 git_tree_lookup( &rawTree, repo, &tree_id );
1054 std::unique_ptr<git_tree, decltype( &git_tree_free )> tree( rawTree, &git_tree_free );
1055
1056 git_signature* rawSig = nullptr;
1057 git_signature_now( &rawSig, "KiCad", "[email protected]" );
1058 std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( rawSig,
1059 &git_signature_free );
1060
1061 git_commit* rawParent = nullptr;
1062 git_oid parent_id;
1063 int parents = 0;
1064
1065 if( git_reference_name_to_id( &parent_id, repo, "HEAD" ) == 0 )
1066 {
1067 git_commit_lookup( &rawParent, repo, &parent_id );
1068 parents = 1;
1069 }
1070
1071 std::unique_ptr<git_commit, decltype( &git_commit_free )> parent( rawParent,
1072 &git_commit_free );
1073
1074 git_tree* rawParentTree = nullptr;
1075
1076 if( parent )
1077 git_commit_tree( &rawParentTree, parent.get() );
1078
1079 std::unique_ptr<git_tree, decltype( &git_tree_free )> parentTree( rawParentTree, &git_tree_free );
1080
1081 git_diff* rawDiff = nullptr;
1082 git_diff_tree_to_index( &rawDiff, repo, parentTree.get(), index, nullptr );
1083 std::unique_ptr<git_diff, decltype( &git_diff_free )> diff( rawDiff, &git_diff_free );
1084
1085 size_t numChangedFiles = git_diff_num_deltas( diff.get() );
1086
1087 if( numChangedFiles == 0 )
1088 {
1089 wxLogTrace( traceAutoSave, wxS( "No actual changes in tree, skipping commit" ) );
1091 }
1092
1093 wxString msg;
1094
1095 if( !aTitle.IsEmpty() )
1096 msg << aTitle << wxS( ": " );
1097
1098 msg << numChangedFiles << wxS( " files changed" );
1099
1100 for( size_t i = 0; i < numChangedFiles; ++i )
1101 {
1102 const git_diff_delta* delta = git_diff_get_delta( diff.get(), i );
1103 git_patch* rawPatch = nullptr;
1104 git_patch_from_diff( &rawPatch, diff.get(), i );
1105 std::unique_ptr<git_patch, decltype( &git_patch_free )> patch( rawPatch,
1106 &git_patch_free );
1107 size_t context = 0, adds = 0, dels = 0;
1108 git_patch_line_stats( &context, &adds, &dels, patch.get() );
1109 size_t updated = std::min( adds, dels );
1110 adds -= updated;
1111 dels -= updated;
1112 msg << wxS( "\n" ) << wxString::FromUTF8( delta->new_file.path )
1113 << wxS( " " ) << adds << wxS( "/" ) << dels << wxS( "/" ) << updated;
1114 }
1115
1116 git_oid commit_id;
1117 git_commit* parentPtr = parent.get();
1118 const git_commit* constParentPtr = parentPtr;
1119 if( git_commit_create( &commit_id, repo, "HEAD", sig.get(), sig.get(), nullptr, msg.mb_str().data(), tree.get(),
1120 parents, parentPtr ? &constParentPtr : nullptr )
1121 != 0 )
1122 {
1124 }
1125
1126 git_index_write( index );
1128}
1129
1130
1131// Internal entry point used when the project root is already known. The public
1132// CommitSnapshot() derives the project from aFiles[0], which is unsafe when the
1133// caller has collected files recursively (the first entry can live in a subdirectory).
1134static bool commitSnapshotForProject( const wxString& aProjectPath, const std::vector<wxString>& aFiles,
1135 const wxString& aTitle )
1136{
1137 wxString hist = historyPath( aProjectPath );
1138
1139 HISTORY_LOCK_MANAGER lock( aProjectPath );
1140
1141 if( !lock.IsLocked() )
1142 {
1143 wxLogTrace( traceAutoSave, wxS( "[history] commitSnapshotForProject failed to acquire lock: %s" ),
1144 lock.GetLockError() );
1145 return false;
1146 }
1147
1148 return commitSnapshotWithLock( lock.GetRepository(), lock.GetIndex(), hist, aProjectPath, aFiles, aTitle )
1150}
1151
1152
1153bool LOCAL_HISTORY::CommitSnapshot( const std::vector<wxString>& aFiles, const wxString& aTitle )
1154{
1155 if( aFiles.empty() || !Pgm().GetCommonSettings()->m_Backup.enabled
1157 {
1158 return true;
1159 }
1160
1161 wxString proj = wxFileName( aFiles[0] ).GetPath();
1162
1163 if( !isProjectDirectory( proj ) )
1164 return false;
1165
1166 Init( proj );
1167 return commitSnapshotForProject( proj, aFiles, aTitle );
1168}
1169
1170
1171// Limit snapshots to KiCad project artifacts (kicad_* extensions and the no-extension
1172// lib-tables) so unrelated files in the project dir don't end up in .history.
1173static bool isKiCadProjectFile( const wxFileName& aFile )
1174{
1175 wxString name = aFile.GetFullName();
1176
1177 if( name == wxS( "sym-lib-table" ) || name == wxS( "fp-lib-table" ) )
1178 return true;
1179
1180 return aFile.GetExt().StartsWith( wxS( "kicad_" ) );
1181}
1182
1183
1184// Helper to collect KiCad project files (excluding .history, backups, transient caches,
1185// and any non-KiCad files such as user PDFs or notes).
1186// Skips subtrees that contain a kicad_pro file since they belong to nested projects.
1187static void collectProjectFiles( const wxString& aProjectPath, std::vector<wxString>& aFiles )
1188{
1189 wxDir dir( aProjectPath );
1190
1191 if( !dir.IsOpened() )
1192 return;
1193
1194 // Collect recursively. Flag top-level to avoid hitting the same logic for nested projects
1195 std::function<void( const wxString&, bool )> collect =
1196 [&]( const wxString& path, bool topLevel )
1197 {
1198 if( !topLevel && isProjectDirectory( path ) )
1199 {
1200 wxLogTrace( traceAutoSave,
1201 wxS( "[history] collectProjectFiles: Skipping nested project at %s" ),
1202 path );
1203 return;
1204 }
1205
1206 wxString name;
1207 wxDir d( path );
1208
1209 if( !d.IsOpened() )
1210 return;
1211
1212 bool cont = d.GetFirst( &name );
1213
1214 while( cont )
1215 {
1216 if( topLevel && isRestoreProtectedEntry( name ) )
1217 {
1218 cont = d.GetNext( &name );
1219 continue;
1220 }
1221
1222 wxFileName fn( path, name );
1223 wxString fullPath = fn.GetFullPath();
1224
1225 if( wxFileName::DirExists( fullPath ) )
1226 {
1227 collect( fullPath, false );
1228 }
1229 else if( fn.FileExists() && fn.GetFullName() != wxS( "fp-info-cache" ) && isKiCadProjectFile( fn ) )
1230 {
1231 aFiles.push_back( fn.GetFullPath() );
1232 }
1233
1234 cont = d.GetNext( &name );
1235 }
1236 };
1237
1238 collect( aProjectPath, true );
1239}
1240
1241
1242bool LOCAL_HISTORY::CommitFullProjectSnapshot( const wxString& aProjectPath, const wxString& aTitle )
1243{
1244 if( !isProjectDirectory( aProjectPath ) || !Pgm().GetCommonSettings()->m_Backup.enabled )
1245 return false;
1246
1248 {
1249 wxLogTrace( traceAutoSave, wxS("[history] Backup format is ZIP; skipping full snapshot" ) );
1250 return true;
1251 }
1252
1253 std::vector<wxString> files;
1254 collectProjectFiles( aProjectPath, files );
1255
1256 if( files.empty() )
1257 return false;
1258
1259 Init( aProjectPath );
1260 return commitSnapshotForProject( aProjectPath, files, aTitle );
1261}
1262
1263bool LOCAL_HISTORY::HistoryExists( const wxString& aProjectPath )
1264{
1265 return wxDirExists( historyPath( aProjectPath ) );
1266}
1267
1268bool LOCAL_HISTORY::TagSave( const wxString& aProjectPath, const wxString& aFileType )
1269{
1270 if( !Pgm().GetCommonSettings()->m_Backup.enabled || !formatUsesIncrementalHistory() )
1271 return true;
1272
1273 if( !isProjectDirectory( aProjectPath ) )
1274 return false;
1275
1276 HISTORY_LOCK_MANAGER lock( aProjectPath );
1277
1278 if( !lock.IsLocked() )
1279 {
1280 wxLogTrace( traceAutoSave, wxS( "[history] TagSave: Failed to acquire lock for %s" ), aProjectPath );
1281 return false;
1282 }
1283
1284 git_repository* repo = lock.GetRepository();
1285
1286 if( !repo )
1287 return false;
1288
1289 git_oid head;
1290 if( git_reference_name_to_id( &head, repo, "HEAD" ) != 0 )
1291 return false;
1292
1293 wxString tagName;
1294 int i = 1;
1295 git_reference* ref = nullptr;
1296 do
1297 {
1298 tagName.Printf( wxS( "Save_%s_%d" ), aFileType, i++ );
1299 } while( git_reference_lookup( &ref, repo, ( wxS( "refs/tags/" ) + tagName ).mb_str().data() ) == 0 );
1300
1301 git_oid tag_oid;
1302 git_object* head_obj = nullptr;
1303 git_object_lookup( &head_obj, repo, &head, GIT_OBJECT_COMMIT );
1304 git_tag_create_lightweight( &tag_oid, repo, tagName.mb_str().data(), head_obj, 0 );
1305 git_object_free( head_obj );
1306
1307 wxString lastName;
1308 lastName.Printf( wxS( "Last_Save_%s" ), aFileType );
1309 if( git_reference_lookup( &ref, repo, ( wxS( "refs/tags/" ) + lastName ).mb_str().data() ) == 0 )
1310 {
1311 git_reference_delete( ref );
1312 git_reference_free( ref );
1313 }
1314
1315 git_oid last_tag_oid;
1316 git_object* head_obj2 = nullptr;
1317 git_object_lookup( &head_obj2, repo, &head, GIT_OBJECT_COMMIT );
1318 git_tag_create_lightweight( &last_tag_oid, repo, lastName.mb_str().data(), head_obj2, 0 );
1319 git_object_free( head_obj2 );
1320
1321 return true;
1322}
1323
1324bool LOCAL_HISTORY::HeadNewerThanLastSave( const wxString& aProjectPath )
1325{
1326 wxString hist = historyPath( aProjectPath );
1327 git_repository* repo = nullptr;
1328
1329 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
1330 return false;
1331
1332 git_oid head_oid;
1333 if( git_reference_name_to_id( &head_oid, repo, "HEAD" ) != 0 )
1334 {
1335 git_repository_free( repo );
1336 return false;
1337 }
1338
1339 git_commit* head_commit = nullptr;
1340 git_commit_lookup( &head_commit, repo, &head_oid );
1341 git_time_t head_time = git_commit_time( head_commit );
1342
1343 git_strarray tags;
1344 git_tag_list_match( &tags, "Last_Save_*", repo );
1345 git_time_t save_time = 0;
1346
1347 for( size_t i = 0; i < tags.count; ++i )
1348 {
1349 git_reference* ref = nullptr;
1350 if( git_reference_lookup( &ref, repo,
1351 ( wxS( "refs/tags/" ) +
1352 wxString::FromUTF8( tags.strings[i] ) ).mb_str().data() ) == 0 )
1353 {
1354 const git_oid* oid = git_reference_target( ref );
1355 git_commit* c = nullptr;
1356 if( git_commit_lookup( &c, repo, oid ) == 0 )
1357 {
1358 git_time_t t = git_commit_time( c );
1359 if( t > save_time )
1360 save_time = t;
1361 git_commit_free( c );
1362 }
1363 git_reference_free( ref );
1364 }
1365 }
1366
1367 git_strarray_free( &tags );
1368 git_commit_free( head_commit );
1369 git_repository_free( repo );
1370
1371 // If there are no Last_Save tags but there IS a HEAD commit, we have autosaved
1372 // data that was never explicitly saved - offer to restore
1373 if( save_time == 0 )
1374 return true;
1375
1376 return head_time > save_time;
1377}
1378
1379bool LOCAL_HISTORY::CommitDuplicateOfLastSave( const wxString& aProjectPath, const wxString& aFileType,
1380 const wxString& aMessage )
1381{
1382 if( !Pgm().GetCommonSettings()->m_Backup.enabled || !formatUsesIncrementalHistory() )
1383 return true;
1384
1385 if( !isProjectDirectory( aProjectPath ) )
1386 return false;
1387
1388 HISTORY_LOCK_MANAGER lock( aProjectPath );
1389
1390 if( !lock.IsLocked() )
1391 {
1392 wxLogTrace( traceAutoSave, wxS( "[history] CommitDuplicateOfLastSave: Failed to acquire lock for %s" ), aProjectPath );
1393 return false;
1394 }
1395
1396 git_repository* repo = lock.GetRepository();
1397
1398 if( !repo )
1399 return false;
1400
1401 wxString lastName; lastName.Printf( wxS("Last_Save_%s"), aFileType );
1402 git_reference* lastRef = nullptr;
1403 if( git_reference_lookup( &lastRef, repo, ( wxS("refs/tags/") + lastName ).mb_str().data() ) != 0 )
1404 return false; // no tag to duplicate
1405 std::unique_ptr<git_reference, decltype( &git_reference_free )> lastRefPtr( lastRef, &git_reference_free );
1406
1407 const git_oid* lastOid = git_reference_target( lastRef );
1408 git_commit* lastCommit = nullptr;
1409 if( git_commit_lookup( &lastCommit, repo, lastOid ) != 0 )
1410 return false;
1411 std::unique_ptr<git_commit, decltype( &git_commit_free )> lastCommitPtr( lastCommit, &git_commit_free );
1412
1413 git_tree* lastTree = nullptr;
1414 git_commit_tree( &lastTree, lastCommit );
1415 std::unique_ptr<git_tree, decltype( &git_tree_free )> lastTreePtr( lastTree, &git_tree_free );
1416
1417 // Parent will be current HEAD (to keep linear history)
1418 git_oid headOid;
1419 git_commit* headCommit = nullptr;
1420 int parents = 0;
1421 const git_commit* parentArray[1];
1422 if( git_reference_name_to_id( &headOid, repo, "HEAD" ) == 0 &&
1423 git_commit_lookup( &headCommit, repo, &headOid ) == 0 )
1424 {
1425 parentArray[0] = headCommit;
1426 parents = 1;
1427 }
1428
1429 git_signature* sigRaw = nullptr;
1430 git_signature_now( &sigRaw, "KiCad", "[email protected]" );
1431 std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( sigRaw, &git_signature_free );
1432
1433 wxString msg = aMessage.IsEmpty() ? wxS("Discard unsaved ") + aFileType : aMessage;
1434 git_oid newCommitOid;
1435 int rc = git_commit_create( &newCommitOid, repo, "HEAD", sig.get(), sig.get(), nullptr,
1436 msg.mb_str().data(), lastTree, parents, parents ? parentArray : nullptr );
1437 if( headCommit ) git_commit_free( headCommit );
1438 if( rc != 0 )
1439 return false;
1440
1441 // Move Last_Save tag to new commit
1442 git_reference* existing = nullptr;
1443 if( git_reference_lookup( &existing, repo, ( wxS("refs/tags/") + lastName ).mb_str().data() ) == 0 )
1444 {
1445 git_reference_delete( existing );
1446 git_reference_free( existing );
1447 }
1448 git_object* newCommitObj = nullptr;
1449 if( git_object_lookup( &newCommitObj, repo, &newCommitOid, GIT_OBJECT_COMMIT ) == 0 )
1450 {
1451 git_tag_create_lightweight( &newCommitOid, repo, lastName.mb_str().data(), newCommitObj, 0 );
1452 git_object_free( newCommitObj );
1453 }
1454 return true;
1455}
1456
1457static size_t dirSizeRecursive( const wxString& path )
1458{
1459 size_t total = 0;
1460 wxDir dir( path );
1461 if( !dir.IsOpened() )
1462 return 0;
1463 wxString name;
1464 bool cont = dir.GetFirst( &name );
1465 while( cont )
1466 {
1467 wxFileName fn( path, name );
1468 wxString fullPath = fn.GetFullPath();
1469
1470 if( wxFileName::DirExists( fullPath ) )
1471 total += dirSizeRecursive( fullPath );
1472 else if( fn.FileExists() )
1473 total += (size_t) fn.GetSize().GetValue();
1474 cont = dir.GetNext( &name );
1475 }
1476 return total;
1477}
1478
1479// Copy tree and all blob objects directly between ODBs
1480static bool copyTreeObjects( git_repository* aSrcRepo, git_odb* aSrcOdb, git_odb* aDstOdb, const git_oid* aTreeOid,
1481 std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )>& aCopied )
1482{
1483 if( aCopied.count( *aTreeOid ) )
1484 return true;
1485
1486 git_odb_object* obj = nullptr;
1487
1488 if( git_odb_read( &obj, aSrcOdb, aTreeOid ) != 0 )
1489 return false;
1490
1491 git_oid written;
1492 int err = git_odb_write( &written, aDstOdb, git_odb_object_data( obj ), git_odb_object_size( obj ),
1493 git_odb_object_type( obj ) );
1494 git_odb_object_free( obj );
1495
1496 if( err != 0 )
1497 return false;
1498
1499 aCopied.insert( *aTreeOid );
1500
1501 git_tree* tree = nullptr;
1502
1503 if( git_tree_lookup( &tree, aSrcRepo, aTreeOid ) != 0 )
1504 return false;
1505
1506 size_t cnt = git_tree_entrycount( tree );
1507
1508 for( size_t i = 0; i < cnt; ++i )
1509 {
1510 const git_tree_entry* entry = git_tree_entry_byindex( tree, i );
1511 const git_oid* entryId = git_tree_entry_id( entry );
1512
1513 if( aCopied.count( *entryId ) )
1514 continue;
1515
1516 if( git_tree_entry_type( entry ) == GIT_OBJECT_TREE )
1517 {
1518 if( !copyTreeObjects( aSrcRepo, aSrcOdb, aDstOdb, entryId, aCopied ) )
1519 {
1520 git_tree_free( tree );
1521 return false;
1522 }
1523 }
1524 else if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
1525 {
1526 git_odb_object* blobObj = nullptr;
1527
1528 if( git_odb_read( &blobObj, aSrcOdb, entryId ) == 0 )
1529 {
1530 git_oid blobWritten;
1531
1532 if( git_odb_write( &blobWritten, aDstOdb, git_odb_object_data( blobObj ),
1533 git_odb_object_size( blobObj ), git_odb_object_type( blobObj ) )
1534 != 0 )
1535 {
1536 git_odb_object_free( blobObj );
1537 git_tree_free( tree );
1538 return false;
1539 }
1540
1541 git_odb_object_free( blobObj );
1542 aCopied.insert( *entryId );
1543 }
1544 }
1545 }
1546
1547 git_tree_free( tree );
1548 return true;
1549}
1550
1551
1552// Compact loose objects into a packfile and remove the originals.
1553// Equivalent to git gc
1554static bool compactRepository( git_repository* aRepo, PROGRESS_REPORTER* aReporter = nullptr )
1555{
1556 git_packbuilder* pb = nullptr;
1557
1558 if( git_packbuilder_new( &pb, aRepo ) != 0 )
1559 return false;
1560
1561 git_revwalk* walk = nullptr;
1562
1563 if( git_revwalk_new( &walk, aRepo ) != 0 )
1564 {
1565 git_packbuilder_free( pb );
1566 return false;
1567 }
1568
1569 git_revwalk_push_head( walk );
1570 git_oid oid;
1571
1572 while( git_revwalk_next( &oid, walk ) == 0 )
1573 {
1574 if( git_packbuilder_insert_commit( pb, &oid ) != 0 )
1575 {
1576 git_revwalk_free( walk );
1577 git_packbuilder_free( pb );
1578 return false;
1579 }
1580 }
1581
1582 git_revwalk_free( walk );
1583
1584 if( aReporter )
1585 {
1586 git_packbuilder_set_callbacks(
1587 pb,
1588 []( int aStage, uint32_t aCurrent, uint32_t aTotal, void* aPayload )
1589 {
1590 auto* reporter = static_cast<PROGRESS_REPORTER*>( aPayload );
1591
1592 if( aTotal > 0 )
1593 reporter->SetCurrentProgress( (double) aCurrent / aTotal );
1594
1595 reporter->KeepRefreshing();
1596 return 0;
1597 },
1598 aReporter );
1599 }
1600
1601 if( git_packbuilder_write( pb, nullptr, 0, nullptr, nullptr ) != 0 )
1602 {
1603 git_packbuilder_free( pb );
1604 return false;
1605 }
1606
1607 git_packbuilder_free( pb );
1608
1609 wxString objPath = wxString::FromUTF8( git_repository_path( aRepo ) ) + wxS( "objects" );
1610 wxDir objDir( objPath );
1611
1612 if( objDir.IsOpened() )
1613 {
1614 wxArrayString toRemove;
1615 wxString name;
1616 bool cont = objDir.GetFirst( &name, wxEmptyString, wxDIR_DIRS );
1617
1618 while( cont )
1619 {
1620 if( name.length() == 2 )
1621 toRemove.Add( objPath + wxFileName::GetPathSeparator() + name );
1622
1623 cont = objDir.GetNext( &name );
1624 }
1625
1626 for( const wxString& dir : toRemove )
1627 wxFileName::Rmdir( dir, wxPATH_RMDIR_RECURSIVE );
1628 }
1629
1630 return true;
1631}
1632
1633
1634bool LOCAL_HISTORY::EnforceSizeLimit( const wxString& aProjectPath, size_t aMaxBytes, PROGRESS_REPORTER* aReporter )
1635{
1636 if( aMaxBytes == 0 )
1637 return false;
1638
1639 wxString hist = historyPath( aProjectPath );
1640
1641 if( !wxDirExists( hist ) )
1642 return false;
1643
1644 size_t current = dirSizeRecursive( hist );
1645
1646 if( current <= aMaxBytes )
1647 return true; // within limit
1648
1649 HISTORY_LOCK_MANAGER lock( aProjectPath );
1650
1651 if( !lock.IsLocked() )
1652 {
1653 wxLogTrace( traceAutoSave, wxS( "[history] EnforceSizeLimit: Failed to acquire lock for %s" ), aProjectPath );
1654 return false;
1655 }
1656
1657 git_repository* repo = lock.GetRepository();
1658
1659 if( !repo )
1660 return false;
1661
1662 if( aReporter )
1663 aReporter->Report( _( "Compacting local history..." ) );
1664
1665 // Pack loose objects first. Can bring size within limit without a full rebuild.
1666 compactRepository( repo, aReporter );
1667
1668 current = dirSizeRecursive( hist );
1669
1670 if( current <= aMaxBytes )
1671 return true; // within limit after compaction
1672
1673 // Collect commits newest-first using revwalk
1674 git_revwalk* walk = nullptr;
1675 git_revwalk_new( &walk, repo );
1676 git_revwalk_sorting( walk, GIT_SORT_TIME );
1677 git_revwalk_push_head( walk );
1678 std::vector<git_oid> commits;
1679 git_oid oid;
1680
1681 while( git_revwalk_next( &oid, walk ) == 0 )
1682 commits.push_back( oid );
1683
1684 git_revwalk_free( walk );
1685
1686 if( commits.empty() )
1687 return true;
1688
1689 // Determine set of newest commits to keep based on blob sizes.
1690 std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )> seenBlobs(
1691 []( const git_oid& a, const git_oid& b )
1692 {
1693 return memcmp( &a, &b, sizeof( git_oid ) ) < 0;
1694 } );
1695
1696 size_t keptBytes = 0;
1697 std::vector<git_oid> keep;
1698
1699 git_odb* odb = nullptr;
1700 git_repository_odb( &odb, repo );
1701
1702 std::function<size_t( git_tree* )> accountTree = [&]( git_tree* tree )
1703 {
1704 size_t added = 0;
1705 size_t cnt = git_tree_entrycount( tree );
1706
1707 for( size_t i = 0; i < cnt; ++i )
1708 {
1709 const git_tree_entry* entry = git_tree_entry_byindex( tree, i );
1710
1711 if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
1712 {
1713 const git_oid* bid = git_tree_entry_id( entry );
1714
1715 if( seenBlobs.find( *bid ) == seenBlobs.end() )
1716 {
1717 size_t len = 0;
1718 git_object_t type = GIT_OBJECT_ANY;
1719
1720 if( odb && git_odb_read_header( &len, &type, odb, bid ) == 0 )
1721 added += len;
1722
1723 seenBlobs.insert( *bid );
1724 }
1725 }
1726 else if( git_tree_entry_type( entry ) == GIT_OBJECT_TREE )
1727 {
1728 git_tree* sub = nullptr;
1729
1730 if( git_tree_lookup( &sub, repo, git_tree_entry_id( entry ) ) == 0 )
1731 {
1732 added += accountTree( sub );
1733 git_tree_free( sub );
1734 }
1735 }
1736 }
1737
1738 return added;
1739 };
1740
1741 for( const git_oid& cOid : commits )
1742 {
1743 git_commit* c = nullptr;
1744
1745 if( git_commit_lookup( &c, repo, &cOid ) != 0 )
1746 continue;
1747
1748 git_tree* tree = nullptr;
1749 git_commit_tree( &tree, c );
1750 size_t add = accountTree( tree );
1751 git_tree_free( tree );
1752 git_commit_free( c );
1753
1754 if( keep.empty() || keptBytes + add <= aMaxBytes )
1755 {
1756 keep.push_back( cOid );
1757 keptBytes += add;
1758 }
1759 else
1760 break; // stop once limit exceeded
1761 }
1762
1763 if( keep.empty() )
1764 keep.push_back( commits.front() );
1765
1766 // Collect tags we want to preserve (Save_*/Last_Save_*). We'll recreate them if their
1767 // target commit is retained. Also ensure tagged commits are ALWAYS kept.
1768 std::vector<std::pair<wxString, git_oid>> tagTargets;
1769 std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )> taggedCommits(
1770 []( const git_oid& a, const git_oid& b )
1771 {
1772 return memcmp( &a, &b, sizeof( git_oid ) ) < 0;
1773 } );
1774 git_strarray tagList;
1775
1776 if( git_tag_list( &tagList, repo ) == 0 )
1777 {
1778 for( size_t i = 0; i < tagList.count; ++i )
1779 {
1780 wxString name = wxString::FromUTF8( tagList.strings[i] );
1781 if( name.StartsWith( wxS("Save_") ) || name.StartsWith( wxS("Last_Save_") ) )
1782 {
1783 git_reference* tref = nullptr;
1784
1785 if( git_reference_lookup( &tref, repo, ( wxS( "refs/tags/" ) + name ).mb_str().data() ) == 0 )
1786 {
1787 const git_oid* toid = git_reference_target( tref );
1788
1789 if( toid )
1790 {
1791 tagTargets.emplace_back( name, *toid );
1792 taggedCommits.insert( *toid );
1793
1794 // Ensure this tagged commit is in the keep list
1795 bool found = false;
1796 for( const auto& k : keep )
1797 {
1798 if( memcmp( &k, toid, sizeof( git_oid ) ) == 0 )
1799 {
1800 found = true;
1801 break;
1802 }
1803 }
1804
1805 if( !found )
1806 {
1807 // Add tagged commit to keep list (even if it exceeds size limit)
1808 keep.push_back( *toid );
1809 wxLogTrace( traceAutoSave, wxS( "[history] EnforceSizeLimit: Preserving tagged commit %s" ),
1810 name );
1811 }
1812 }
1813
1814 git_reference_free( tref );
1815 }
1816 }
1817 }
1818 git_strarray_free( &tagList );
1819 }
1820
1821 // Rebuild trimmed repo in temp dir
1822 wxFileName trimFn( hist + wxS("_trim"), wxEmptyString );
1823 wxString trimPath = trimFn.GetPath();
1824
1825 if( wxDirExists( trimPath ) )
1826 wxFileName::Rmdir( trimPath, wxPATH_RMDIR_RECURSIVE );
1827
1828 wxMkdir( trimPath );
1829 git_repository* newRepo = nullptr;
1830
1831 if( git_repository_init( &newRepo, trimPath.mb_str().data(), 0 ) != 0 )
1832 {
1833 git_odb_free( odb );
1834 return false;
1835 }
1836
1837 git_odb* dstOdb = nullptr;
1838
1839 if( git_repository_odb( &dstOdb, newRepo ) != 0 )
1840 {
1841 git_repository_free( newRepo );
1842 git_odb_free( odb );
1843 return false;
1844 }
1845
1846 std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )> copiedObjects(
1847 []( const git_oid& a, const git_oid& b )
1848 {
1849 return memcmp( &a, &b, sizeof( git_oid ) ) < 0;
1850 } );
1851
1852 // Replay kept commits chronologically (oldest first) to preserve order.
1853 std::reverse( keep.begin(), keep.end() );
1854 git_commit* parent = nullptr;
1855 struct MAP_ENTRY { git_oid orig; git_oid neu; };
1856 std::vector<MAP_ENTRY> commitMap;
1857
1858 if( aReporter )
1859 {
1860 aReporter->AdvancePhase( _( "Trimming local history..." ) );
1861 aReporter->SetCurrentProgress( 0 );
1862 }
1863
1864 for( size_t idx = 0; idx < keep.size(); ++idx )
1865 {
1866 if( aReporter )
1867 aReporter->SetCurrentProgress( (double) idx / keep.size() );
1868
1869 const git_oid& co = keep[idx];
1870 git_commit* orig = nullptr;
1871
1872 if( git_commit_lookup( &orig, repo, &co ) != 0 )
1873 continue;
1874
1875 git_tree* tree = nullptr;
1876 git_commit_tree( &tree, orig );
1877
1878 copyTreeObjects( repo, odb, dstOdb, git_tree_id( tree ), copiedObjects );
1879
1880 git_tree* newTree = nullptr;
1881 git_tree_lookup( &newTree, newRepo, git_tree_id( tree ) );
1882
1883 git_tree_free( tree );
1884
1885 // Recreate original author/committer signatures preserving timestamp.
1886 const git_signature* origAuthor = git_commit_author( orig );
1887 const git_signature* origCommitter = git_commit_committer( orig );
1888 git_signature* sigAuthor = nullptr;
1889 git_signature* sigCommitter = nullptr;
1890
1891 git_signature_new( &sigAuthor, origAuthor->name, origAuthor->email,
1892 origAuthor->when.time, origAuthor->when.offset );
1893 git_signature_new( &sigCommitter, origCommitter->name, origCommitter->email,
1894 origCommitter->when.time, origCommitter->when.offset );
1895
1896 const git_commit* parents[1];
1897 int parentCount = 0;
1898
1899 if( parent )
1900 {
1901 parents[0] = parent;
1902 parentCount = 1;
1903 }
1904
1905 git_oid newCommitOid;
1906 git_commit_create( &newCommitOid, newRepo, "HEAD", sigAuthor, sigCommitter, nullptr, git_commit_message( orig ),
1907 newTree, parentCount, parentCount ? parents : nullptr );
1908
1909 if( parent )
1910 git_commit_free( parent );
1911
1912 git_commit_lookup( &parent, newRepo, &newCommitOid );
1913
1914 commitMap.emplace_back( co, newCommitOid );
1915
1916 git_signature_free( sigAuthor );
1917 git_signature_free( sigCommitter );
1918 git_tree_free( newTree );
1919 git_commit_free( orig );
1920 }
1921
1922 if( parent )
1923 git_commit_free( parent );
1924
1925 // Recreate preserved tags pointing to new commit OIDs where possible.
1926 for( const auto& tt : tagTargets )
1927 {
1928 // Find mapping
1929 const git_oid* newOid = nullptr;
1930
1931 for( const auto& m : commitMap )
1932 {
1933 if( memcmp( &m.orig, &tt.second, sizeof( git_oid ) ) == 0 )
1934 {
1935 newOid = &m.neu;
1936 break;
1937 }
1938 }
1939
1940 if( !newOid )
1941 continue; // commit trimmed away
1942
1943 git_object* obj = nullptr;
1944
1945 if( git_object_lookup( &obj, newRepo, newOid, GIT_OBJECT_COMMIT ) == 0 )
1946 {
1947 git_oid tag_oid; git_tag_create_lightweight( &tag_oid, newRepo, tt.first.mb_str().data(), obj, 0 );
1948 git_object_free( obj );
1949 }
1950 }
1951
1952 if( aReporter )
1953 aReporter->AdvancePhase( _( "Compacting trimmed history..." ) );
1954
1955 compactRepository( newRepo, aReporter );
1956
1957 // Free ODBs and close repos before swapping directories to avoid file locking issues.
1958 // Note: The lock manager will automatically free the original repo when it goes out of scope,
1959 // but we need to manually free the ODBs and new trimmed repo we created.
1960 git_odb_free( dstOdb );
1961 git_odb_free( odb );
1962 git_repository_free( newRepo );
1963
1964 lock.ReleaseRepository();
1965
1966 // Replace old history dir with trimmed one
1967 wxString backupOld = hist + wxS("_old");
1968 wxRenameFile( hist, backupOld );
1969 wxRenameFile( trimPath, hist );
1970 wxFileName::Rmdir( backupOld, wxPATH_RMDIR_RECURSIVE );
1971 return true;
1972}
1973
1974wxString LOCAL_HISTORY::GetHeadHash( const wxString& aProjectPath )
1975{
1976 wxString hist = historyPath( aProjectPath );
1977 git_repository* repo = nullptr;
1978
1979 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
1980 return wxEmptyString;
1981
1982 git_oid head_oid;
1983 if( git_reference_name_to_id( &head_oid, repo, "HEAD" ) != 0 )
1984 {
1985 git_repository_free( repo );
1986 return wxEmptyString;
1987 }
1988
1989 wxString hash = wxString::FromUTF8( git_oid_tostr_s( &head_oid ) );
1990 git_repository_free( repo );
1991 return hash;
1992}
1993
1994
1995// Helper functions for RestoreCommit
1996namespace
1997{
1998
2002bool checkForLockedFiles( const wxString& aProjectPath, std::vector<wxString>& aLockedFiles )
2003{
2004 std::function<void( const wxString& )> findLocks = [&]( const wxString& dirPath )
2005 {
2006 wxDir dir( dirPath );
2007 if( !dir.IsOpened() )
2008 return;
2009
2010 wxString filename;
2011 bool cont = dir.GetFirst( &filename );
2012
2013 while( cont )
2014 {
2015 wxFileName fullPath( dirPath, filename );
2016
2017 // Skip special directories
2018 if( filename == wxS(".history") || filename == wxS(".git") )
2019 {
2020 cont = dir.GetNext( &filename );
2021 continue;
2022 }
2023
2024 if( fullPath.DirExists() )
2025 {
2026 findLocks( fullPath.GetFullPath() );
2027 }
2028 else if( fullPath.FileExists()
2029 && filename.StartsWith( FILEEXT::LockFilePrefix )
2030 && filename.EndsWith( wxString( wxS( "." ) ) + FILEEXT::LockFileExtension ) )
2031 {
2032 // Reconstruct the original filename from the lock file name
2033 // Lock files are: ~<original>.<ext>.lck -> need to get <original>.<ext>
2034 wxString baseName = filename.Mid( FILEEXT::LockFilePrefix.length() );
2035 baseName = baseName.BeforeLast( '.' ); // Remove .lck
2036 wxFileName originalFile( dirPath, baseName );
2037
2038 // Check if this is a valid LOCKFILE (not stale and not ours)
2039 LOCKFILE testLock( originalFile.GetFullPath() );
2040 if( testLock.Valid() && !testLock.IsLockedByMe() )
2041 {
2042 aLockedFiles.push_back( fullPath.GetFullPath() );
2043 }
2044 }
2045
2046 cont = dir.GetNext( &filename );
2047 }
2048 };
2049
2050 findLocks( aProjectPath );
2051 return aLockedFiles.empty();
2052}
2053
2054
2058bool extractCommitToTemp( git_repository* aRepo, git_tree* aTree, const wxString& aTempPath )
2059{
2060 bool extractSuccess = true;
2061
2062 std::function<void( git_tree*, const wxString& )> extractTree =
2063 [&]( git_tree* t, const wxString& prefix )
2064 {
2065 if( !extractSuccess )
2066 return;
2067
2068 size_t cnt = git_tree_entrycount( t );
2069 for( size_t i = 0; i < cnt; ++i )
2070 {
2071 const git_tree_entry* entry = git_tree_entry_byindex( t, i );
2072 wxString name = wxString::FromUTF8( git_tree_entry_name( entry ) );
2073 wxString fullPath = prefix.IsEmpty() ? name : prefix + wxS("/") + name;
2074
2075 if( git_tree_entry_type( entry ) == GIT_OBJECT_TREE )
2076 {
2077 wxFileName dirPath( aTempPath + wxFileName::GetPathSeparator() + fullPath,
2078 wxEmptyString );
2079 if( !wxFileName::Mkdir( dirPath.GetPath(), 0777, wxPATH_MKDIR_FULL ) )
2080 {
2081 wxLogTrace( traceAutoSave,
2082 wxS( "[history] extractCommitToTemp: Failed to create directory '%s'" ),
2083 dirPath.GetPath() );
2084 extractSuccess = false;
2085 return;
2086 }
2087
2088 git_tree* sub = nullptr;
2089 if( git_tree_lookup( &sub, aRepo, git_tree_entry_id( entry ) ) == 0 )
2090 {
2091 extractTree( sub, fullPath );
2092 git_tree_free( sub );
2093 }
2094 }
2095 else if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
2096 {
2097 git_blob* blob = nullptr;
2098 if( git_blob_lookup( &blob, aRepo, git_tree_entry_id( entry ) ) == 0 )
2099 {
2100 wxFileName dst( aTempPath + wxFileName::GetPathSeparator() + fullPath );
2101
2102 wxFileName dstDir( dst );
2103 dstDir.SetFullName( wxEmptyString );
2104 wxFileName::Mkdir( dstDir.GetPath(), 0777, wxPATH_MKDIR_FULL );
2105
2106 wxFFile f( dst.GetFullPath(), wxT( "wb" ) );
2107 if( f.IsOpened() )
2108 {
2109 f.Write( git_blob_rawcontent( blob ), git_blob_rawsize( blob ) );
2110 f.Close();
2111 }
2112 else
2113 {
2114 wxLogTrace( traceAutoSave,
2115 wxS( "[history] extractCommitToTemp: Failed to write '%s'" ),
2116 dst.GetFullPath() );
2117 extractSuccess = false;
2118 git_blob_free( blob );
2119 return;
2120 }
2121
2122 git_blob_free( blob );
2123 }
2124 }
2125 }
2126 };
2127
2128 extractTree( aTree, wxEmptyString );
2129 return extractSuccess;
2130}
2131
2132
2136void collectFilesInDirectory( const wxString& aRootPath, const wxString& aSearchPath,
2137 std::set<wxString>& aFiles )
2138{
2139 wxDir dir( aSearchPath );
2140 if( !dir.IsOpened() )
2141 return;
2142
2143 wxString filename;
2144 bool cont = dir.GetFirst( &filename );
2145
2146 while( cont )
2147 {
2148 wxFileName fullPath( aSearchPath, filename );
2149 wxString relativePath = fullPath.GetFullPath().Mid( aRootPath.Length() + 1 );
2150
2151 if( fullPath.IsDir() && fullPath.DirExists() )
2152 {
2153 collectFilesInDirectory( aRootPath, fullPath.GetFullPath(), aFiles );
2154 }
2155 else if( fullPath.FileExists() )
2156 {
2157 aFiles.insert( relativePath );
2158 }
2159
2160 cont = dir.GetNext( &filename );
2161 }
2162}
2163
2164
2168bool shouldExcludeFromBackup( const wxString& aFilename )
2169{
2170 // Files explicitly excluded from backup should not be deleted during restore
2171 return aFilename == wxS( "fp-info-cache" ) || isRestoreProtectedEntry( aFilename );
2172}
2173
2174
2175bool isPathUnderNestedProject( const wxString& aProjectPath, const wxString& aRelativePath )
2176{
2177 if( aRelativePath.IsEmpty() )
2178 return false;
2179
2180 wxArrayString parts = wxSplit( aRelativePath, '/', '\0' );
2181
2182 if( parts.GetCount() < 2 )
2183 return false;
2184
2185 wxString accumulated = aProjectPath;
2186
2187 // Walk every ancestor directory of the file, stopping before the file itself. The project
2188 // root is excluded because its .kicad_pro is the one we are restoring, not a nested one.
2189 for( size_t i = 0; i + 1 < parts.GetCount(); ++i )
2190 {
2191 accumulated += wxFileName::GetPathSeparator() + parts[i];
2192
2193 if( isProjectDirectory( accumulated ) )
2194 return true;
2195 }
2196
2197 return false;
2198}
2199
2200
2204void findFilesToDelete( const wxString& aProjectPath, const std::set<wxString>& aRestoredFiles,
2205 std::vector<wxString>& aFilesToDelete )
2206{
2207 std::function<void( const wxString&, const wxString& )> scanDirectory =
2208 [&]( const wxString& dirPath, const wxString& relativeBase )
2209 {
2210 wxDir dir( dirPath );
2211 if( !dir.IsOpened() )
2212 return;
2213
2214 wxString filename;
2215 bool cont = dir.GetFirst( &filename );
2216
2217 while( cont )
2218 {
2219 // Protected entries only exist at the top level; skipping here also prevents
2220 // recursion into them.
2221 if( relativeBase.IsEmpty() && isRestoreProtectedEntry( filename ) )
2222 {
2223 cont = dir.GetNext( &filename );
2224 continue;
2225 }
2226
2227 wxFileName fullPath( dirPath, filename );
2228 wxString relativePath = relativeBase.IsEmpty() ? filename :
2229 relativeBase + wxS("/") + filename;
2230
2231 if( fullPath.IsDir() && fullPath.DirExists() )
2232 {
2233 // Skip nested projects entirely. Their files belong to a different .kicad_pro
2234 // and must never be proposed for deletion by the parent's restore.
2235 if( isProjectDirectory( fullPath.GetFullPath() ) )
2236 {
2237 wxLogTrace( traceAutoSave,
2238 wxS( "[history] findFilesToDelete: Skipping nested project "
2239 "subtree at %s" ),
2240 fullPath.GetFullPath() );
2241 }
2242 else
2243 {
2244 scanDirectory( fullPath.GetFullPath(), relativePath );
2245 }
2246 }
2247 else if( fullPath.FileExists() )
2248 {
2249 // Check if this file exists in the restored commit
2250 if( aRestoredFiles.find( relativePath ) == aRestoredFiles.end() )
2251 {
2252 // Don't propose deletion of files that were never in backup scope
2253 if( !shouldExcludeFromBackup( filename ) )
2254 aFilesToDelete.push_back( relativePath );
2255 }
2256 }
2257
2258 cont = dir.GetNext( &filename );
2259 }
2260 };
2261
2262 scanDirectory( aProjectPath, wxEmptyString );
2263}
2264
2265
2270bool confirmFileDeletion( wxWindow* aParent, const wxString& aProjectPath,
2271 const wxString& aBackupPath,
2272 const std::vector<wxString>& aFilesToDelete, bool& aKeepAllFiles )
2273{
2274 if( aFilesToDelete.empty() || !aParent )
2275 {
2276 aKeepAllFiles = true;
2277 return true;
2278 }
2279
2280 bool hasNestedProjectFile = false;
2281
2282 for( const wxString& rel : aFilesToDelete )
2283 {
2284 if( isPathUnderNestedProject( aProjectPath, rel ) )
2285 {
2286 hasNestedProjectFile = true;
2287 break;
2288 }
2289 }
2290
2291 if( hasNestedProjectFile )
2292 {
2293 wxLogTrace( traceAutoSave, wxS( "[history] Forcing keepAllFiles due to nested project under "
2294 "candidate path" ) );
2295 aKeepAllFiles = true;
2296 return true;
2297 }
2298
2299 wxString message = _( "The following files will be deleted when restoring this commit:\n\n" );
2300
2301 // Limit display to first 20 files to avoid overwhelming dialog
2302 size_t displayCount = std::min( aFilesToDelete.size(), size_t(20) );
2303 for( size_t i = 0; i < displayCount; ++i )
2304 {
2305 message += wxS(" • ") + aFilesToDelete[i] + wxS("\n");
2306 }
2307
2308 if( aFilesToDelete.size() > displayCount )
2309 {
2310 message += wxString::Format( _( "\n... and %zu more files\n" ),
2311 aFilesToDelete.size() - displayCount );
2312 }
2313
2314 KICAD_MESSAGE_DIALOG dlg( aParent, message, _( "Delete Files during Restore" ),
2315 wxYES_NO | wxCANCEL | wxNO_DEFAULT | wxICON_QUESTION );
2316 dlg.SetYesNoCancelLabels( _( "Proceed" ), _( "Keep All Files" ), _( "Abort" ) );
2317 dlg.SetExtendedMessage(
2318 _( "Choosing 'Keep All Files' will restore the selected commit but retain any existing "
2319 "files in the project directory. Choosing 'Proceed' will delete files that are not "
2320 "present in the restored commit." )
2321 + wxS( "\n\n" )
2322 + wxString::Format( _( "Files removed by 'Proceed' are archived to %s and can be "
2323 "recovered manually." ),
2324 aBackupPath ) );
2325
2326 int choice = dlg.ShowModal();
2327
2328 if( choice == wxID_CANCEL )
2329 {
2330 wxLogTrace( traceAutoSave, wxS( "[history] User cancelled restore" ) );
2331 return false;
2332 }
2333 else if( choice == wxID_NO ) // Keep All Files
2334 {
2335 wxLogTrace( traceAutoSave, wxS( "[history] User chose to keep all files" ) );
2336 aKeepAllFiles = true;
2337 }
2338 else // Proceed with deletion
2339 {
2340 wxLogTrace( traceAutoSave, wxS( "[history] User chose to proceed with deletion" ) );
2341 aKeepAllFiles = false;
2342 }
2343
2344 return true;
2345}
2346
2347
2351bool backupCurrentFiles( const wxString& aProjectPath, const wxString& aBackupPath,
2352 const wxString& aTempRestorePath, bool aKeepAllFiles,
2353 std::set<wxString>& aBackedUpFiles )
2354{
2355 wxDir currentDir( aProjectPath );
2356 if( !currentDir.IsOpened() )
2357 return false;
2358
2359 wxString filename;
2360 bool cont = currentDir.GetFirst( &filename );
2361
2362 while( cont )
2363 {
2364 // _restore_backup is deleted unconditionally after a successful restore, so protected
2365 // entries (especially the zip-backups folder) must never be moved into it.
2366 if( !isRestoreProtectedEntry( filename ) )
2367 {
2368 // If keepAllFiles is true, only backup files that will be overwritten
2369 bool shouldBackup = !aKeepAllFiles;
2370
2371 if( aKeepAllFiles )
2372 {
2373 // Check if this file exists in the restored commit
2374 wxFileName testPath( aTempRestorePath, filename );
2375 shouldBackup = testPath.Exists();
2376 }
2377
2378 if( shouldBackup )
2379 {
2380 wxFileName source( aProjectPath, filename );
2381 wxFileName dest( aBackupPath, filename );
2382
2383 // Create backup directory if needed
2384 if( !wxDirExists( aBackupPath ) )
2385 {
2386 wxLogTrace( traceAutoSave,
2387 wxS( "[history] backupCurrentFiles: Creating backup directory %s" ),
2388 aBackupPath );
2389 wxFileName::Mkdir( aBackupPath, 0777, wxPATH_MKDIR_FULL );
2390 }
2391
2392 wxLogTrace( traceAutoSave,
2393 wxS( "[history] backupCurrentFiles: Backing up '%s' to '%s'" ),
2394 source.GetFullPath(), dest.GetFullPath() );
2395
2396 if( !wxRenameFile( source.GetFullPath(), dest.GetFullPath() ) )
2397 {
2398 wxLogTrace( traceAutoSave,
2399 wxS( "[history] backupCurrentFiles: Failed to backup '%s'" ),
2400 source.GetFullPath() );
2401 return false;
2402 }
2403
2404 aBackedUpFiles.insert( filename );
2405 }
2406 }
2407 cont = currentDir.GetNext( &filename );
2408 }
2409
2410 return true;
2411}
2412
2413
2417bool restoreFilesFromTemp( const wxString& aTempRestorePath, const wxString& aProjectPath,
2418 std::set<wxString>& aRestoredFiles )
2419{
2420 wxDir tempDir( aTempRestorePath );
2421 if( !tempDir.IsOpened() )
2422 return false;
2423
2424 wxString filename;
2425 bool cont = tempDir.GetFirst( &filename );
2426
2427 while( cont )
2428 {
2429 wxFileName source( aTempRestorePath, filename );
2430 wxFileName dest( aProjectPath, filename );
2431
2432 wxLogTrace( traceAutoSave,
2433 wxS( "[history] restoreFilesFromTemp: Restoring '%s' to '%s'" ),
2434 source.GetFullPath(), dest.GetFullPath() );
2435
2436 if( !wxRenameFile( source.GetFullPath(), dest.GetFullPath() ) )
2437 {
2438 wxLogTrace( traceAutoSave,
2439 wxS( "[history] restoreFilesFromTemp: Failed to move '%s'" ),
2440 source.GetFullPath() );
2441 return false;
2442 }
2443
2444 aRestoredFiles.insert( filename );
2445 cont = tempDir.GetNext( &filename );
2446 }
2447
2448 return true;
2449}
2450
2451
2455void rollbackRestore( const wxString& aProjectPath, const wxString& aBackupPath,
2456 const wxString& aTempRestorePath, const std::set<wxString>& aBackedUpFiles,
2457 const std::set<wxString>& aRestoredFiles )
2458{
2459 wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Rolling back due to failure" ) );
2460
2461 // Remove ONLY the files we successfully moved from temp directory
2462 // This preserves any files that were NOT in the backup (never tracked in history)
2463 for( const wxString& filename : aRestoredFiles )
2464 {
2465 wxFileName toRemove( aProjectPath, filename );
2466 wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Removing '%s'" ),
2467 toRemove.GetFullPath() );
2468
2469 if( toRemove.DirExists() )
2470 {
2471 wxFileName::Rmdir( toRemove.GetFullPath(), wxPATH_RMDIR_RECURSIVE );
2472 }
2473 else if( toRemove.FileExists() )
2474 {
2475 wxRemoveFile( toRemove.GetFullPath() );
2476 }
2477 }
2478
2479 // Restore from backup - put back only what we moved
2480 if( wxDirExists( aBackupPath ) )
2481 {
2482 for( const wxString& filename : aBackedUpFiles )
2483 {
2484 wxFileName source( aBackupPath, filename );
2485 wxFileName dest( aProjectPath, filename );
2486
2487 if( source.Exists() )
2488 {
2489 wxRenameFile( source.GetFullPath(), dest.GetFullPath() );
2490 wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Restored '%s'" ),
2491 dest.GetFullPath() );
2492 }
2493 }
2494 }
2495
2496 // Clean up temporary directories
2497 wxFileName::Rmdir( aTempRestorePath, wxPATH_RMDIR_RECURSIVE );
2498 wxFileName::Rmdir( aBackupPath, wxPATH_RMDIR_RECURSIVE );
2499}
2500
2501
2505bool recordRestoreInHistory( git_repository* aRepo, git_commit* aCommit, git_tree* aTree,
2506 const wxString& aHash )
2507{
2508 git_time_t t = git_commit_time( aCommit );
2509 wxDateTime dt( (time_t) t );
2510 git_signature* sig = nullptr;
2511 git_signature_now( &sig, "KiCad", "[email protected]" );
2512 git_commit* parent = nullptr;
2513 git_oid parent_id;
2514
2515 if( git_reference_name_to_id( &parent_id, aRepo, "HEAD" ) == 0 )
2516 git_commit_lookup( &parent, aRepo, &parent_id );
2517
2518 wxString msg;
2519 msg.Printf( wxS( "Restored from %s %s" ), aHash, dt.FormatISOCombined().c_str() );
2520
2521 git_oid new_id;
2522 const git_commit* constParent = parent;
2523 int result = git_commit_create( &new_id, aRepo, "HEAD", sig, sig, nullptr,
2524 msg.mb_str().data(), aTree, parent ? 1 : 0,
2525 parent ? &constParent : nullptr );
2526
2527 if( parent )
2528 git_commit_free( parent );
2529 git_signature_free( sig );
2530
2531 return result == 0;
2532}
2533
2534} // namespace
2535
2536
2537bool LOCAL_HISTORY::RestoreCommit( const wxString& aProjectPath, const wxString& aHash,
2538 wxWindow* aParent )
2539{
2540 // STEP 1: Verify no files are open by checking for LOCKFILEs
2541 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Checking for open files in %s" ),
2542 aProjectPath );
2543
2544 std::vector<wxString> lockedFiles;
2545 if( !checkForLockedFiles( aProjectPath, lockedFiles ) )
2546 {
2547 wxString lockList;
2548 for( const auto& f : lockedFiles )
2549 lockList += wxS("\n - ") + f;
2550
2551 wxLogTrace( traceAutoSave,
2552 wxS( "[history] RestoreCommit: Cannot restore - files are open:%s" ),
2553 lockList );
2554
2555 // Show user-visible warning dialog
2556 if( aParent )
2557 {
2558 wxString msg = _( "Cannot restore - the following files are open by another user:" );
2559 msg += lockList;
2560 wxMessageBox( msg, _( "Restore Failed" ), wxOK | wxICON_WARNING, aParent );
2561 }
2562 return false;
2563 }
2564
2565 // STEP 2: Acquire history lock and verify target commit
2566 HISTORY_LOCK_MANAGER lock( aProjectPath );
2567
2568 if( !lock.IsLocked() )
2569 {
2570 wxLogTrace( traceAutoSave,
2571 wxS( "[history] RestoreCommit: Failed to acquire lock for %s" ),
2572 aProjectPath );
2573 return false;
2574 }
2575
2576 git_repository* repo = lock.GetRepository();
2577 if( !repo )
2578 return false;
2579
2580 // Verify the target commit exists
2581 git_oid oid;
2582 if( git_oid_fromstr( &oid, aHash.mb_str().data() ) != 0 )
2583 {
2584 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Invalid hash %s" ), aHash );
2585 return false;
2586 }
2587
2588 git_commit* commit = nullptr;
2589 if( git_commit_lookup( &commit, repo, &oid ) != 0 )
2590 {
2591 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Commit not found %s" ), aHash );
2592 return false;
2593 }
2594
2595 git_tree* tree = nullptr;
2596 git_commit_tree( &tree, commit );
2597
2598 // Create pre-restore backup snapshot using the existing lock
2599 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Creating pre-restore backup" ) );
2600
2601 std::vector<wxString> backupFiles;
2602 collectProjectFiles( aProjectPath, backupFiles );
2603
2604 if( !backupFiles.empty() )
2605 {
2606 wxString hist = historyPath( aProjectPath );
2607 SNAPSHOT_COMMIT_RESULT backupResult = commitSnapshotWithLock( repo, lock.GetIndex(), hist, aProjectPath,
2608 backupFiles, wxS( "Pre-restore backup" ) );
2609
2610 if( backupResult == SNAPSHOT_COMMIT_RESULT::Error )
2611 {
2612 wxLogTrace( traceAutoSave,
2613 wxS( "[history] RestoreCommit: Failed to create pre-restore backup" ) );
2614 git_tree_free( tree );
2615 git_commit_free( commit );
2616 return false;
2617 }
2618
2619 if( backupResult == SNAPSHOT_COMMIT_RESULT::NoChanges )
2620 {
2621 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Current state already matches HEAD; "
2622 "continuing without a new backup commit" ) );
2623 }
2624 }
2625
2626 // STEP 3: Extract commit to temporary location
2627 wxString tempRestorePath = aProjectPath + wxS("_restore_temp");
2628
2629 if( wxDirExists( tempRestorePath ) )
2630 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
2631
2632 if( !wxFileName::Mkdir( tempRestorePath, 0777, wxPATH_MKDIR_FULL ) )
2633 {
2634 wxLogTrace( traceAutoSave,
2635 wxS( "[history] RestoreCommit: Failed to create temp directory %s" ),
2636 tempRestorePath );
2637 git_tree_free( tree );
2638 git_commit_free( commit );
2639 return false;
2640 }
2641
2642 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Extracting to temp location %s" ),
2643 tempRestorePath );
2644
2645 if( !extractCommitToTemp( repo, tree, tempRestorePath ) )
2646 {
2647 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Extraction failed, cleaning up" ) );
2648 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
2649 git_tree_free( tree );
2650 git_commit_free( commit );
2651 return false;
2652 }
2653
2654 // STEP 4: Determine which files will be deleted and ask for confirmation
2655 std::set<wxString> restoredFiles;
2656 collectFilesInDirectory( tempRestorePath, tempRestorePath, restoredFiles );
2657
2658 std::vector<wxString> filesToDelete;
2659 findFilesToDelete( aProjectPath, restoredFiles, filesToDelete );
2660
2661 // Each restore gets a unique, timestamped backup directory that is retained on success
2662 // so the user can recover any displaced file. Pruning is done by a separate maintenance
2663 // pass, never by RestoreCommit. Windows path-safe (no ':'); ms suffix avoids collisions
2664 // when restores fire within the same second. Computed up-front so the confirmation
2665 // dialog can show the user where their files will go.
2666 wxString backupPath =
2667 aProjectPath + wxS( "_restore_backup_" )
2668 + wxDateTime::UNow().Format( wxS( "%Y-%m-%dT%H-%M-%S-%l" ) );
2669
2670 bool keepAllFiles = true;
2671 if( !confirmFileDeletion( aParent, aProjectPath, backupPath, filesToDelete, keepAllFiles ) )
2672 {
2673 // User cancelled
2674 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
2675 git_tree_free( tree );
2676 git_commit_free( commit );
2677 return false;
2678 }
2679
2680 // STEP 5: Perform atomic swap - backup current, move temp to current
2681 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Performing atomic swap" ) );
2682
2683 // Track which files we moved to backup and restored (for rollback)
2684 std::set<wxString> backedUpFiles;
2685 std::set<wxString> restoredFilesSet;
2686
2687 // Backup current files
2688 if( !backupCurrentFiles( aProjectPath, backupPath, tempRestorePath, keepAllFiles,
2689 backedUpFiles ) )
2690 {
2691 rollbackRestore( aProjectPath, backupPath, tempRestorePath, backedUpFiles,
2692 restoredFilesSet );
2693 git_tree_free( tree );
2694 git_commit_free( commit );
2695 return false;
2696 }
2697
2698 // Restore files from temp
2699 if( !restoreFilesFromTemp( tempRestorePath, aProjectPath, restoredFilesSet ) )
2700 {
2701 rollbackRestore( aProjectPath, backupPath, tempRestorePath, backedUpFiles,
2702 restoredFilesSet );
2703 git_tree_free( tree );
2704 git_commit_free( commit );
2705 return false;
2706 }
2707
2708 // The backup directory is retained so the user can recover any displaced file.
2709 wxLogTrace( traceAutoSave,
2710 wxS( "[history] RestoreCommit: Restore successful, backup retained at %s" ),
2711 backupPath );
2712 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
2713
2714 // Record the restore in history
2715 recordRestoreInHistory( repo, commit, tree, aHash );
2716
2717 git_tree_free( tree );
2718 git_commit_free( commit );
2719
2720 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Complete" ) );
2721 return true;
2722}
2723
2724void LOCAL_HISTORY::ShowRestoreDialog( const wxString& aProjectPath, wxWindow* aParent )
2725{
2726 if( !HistoryExists( aProjectPath ) )
2727 return;
2728
2729 std::vector<LOCAL_HISTORY_SNAPSHOT_INFO> snapshots = LoadSnapshots( aProjectPath );
2730
2731 if( snapshots.empty() )
2732 return;
2733
2734 DIALOG_RESTORE_LOCAL_HISTORY dlg( aParent, snapshots );
2735
2736 if( dlg.ShowModal() == wxID_OK )
2737 {
2738 wxString selectedHash = dlg.GetSelectedHash();
2739
2740 if( !selectedHash.IsEmpty() )
2741 RestoreCommit( aProjectPath, selectedHash, aParent );
2742 }
2743}
2744
2745std::vector<LOCAL_HISTORY_SNAPSHOT_INFO> LOCAL_HISTORY::LoadSnapshots( const wxString& aProjectPath )
2746{
2747 std::vector<LOCAL_HISTORY_SNAPSHOT_INFO> snapshots;
2748
2749 wxString hist = historyPath( aProjectPath );
2750 git_repository* repo = nullptr;
2751
2752 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
2753 return snapshots;
2754
2755 git_revwalk* walk = nullptr;
2756 if( git_revwalk_new( &walk, repo ) != 0 )
2757 {
2758 git_repository_free( repo );
2759 return snapshots;
2760 }
2761
2762 git_revwalk_sorting( walk, GIT_SORT_TIME );
2763 git_revwalk_push_head( walk );
2764
2765 git_oid oid;
2766
2767 while( git_revwalk_next( &oid, walk ) == 0 )
2768 {
2769 git_commit* commit = nullptr;
2770
2771 if( git_commit_lookup( &commit, repo, &oid ) != 0 )
2772 continue;
2773
2775 info.hash = wxString::FromUTF8( git_oid_tostr_s( &oid ) );
2776 info.date = wxDateTime( static_cast<time_t>( git_commit_time( commit ) ) );
2777 info.message = wxString::FromUTF8( git_commit_message( commit ) );
2778
2779 wxString firstLine = info.message.BeforeFirst( '\n' );
2780
2781 long parsedCount = 0;
2782 wxString remainder;
2783 firstLine.BeforeFirst( ':', &remainder );
2784 remainder.Trim( true ).Trim( false );
2785
2786 if( remainder.EndsWith( wxS( "files changed" ) ) )
2787 {
2788 wxString countText = remainder.BeforeFirst( ' ' );
2789
2790 if( countText.ToLong( &parsedCount ) )
2791 info.filesChanged = static_cast<int>( parsedCount );
2792 }
2793
2794 info.summary = firstLine.BeforeFirst( ':' );
2795
2796 wxString rest;
2797 info.message.BeforeFirst( '\n', &rest );
2798 wxArrayString lines = wxSplit( rest, '\n', '\0' );
2799
2800 for( const wxString& line : lines )
2801 {
2802 if( !line.IsEmpty() )
2803 info.changedFiles.Add( line );
2804 }
2805
2806 snapshots.push_back( std::move( info ) );
2807 git_commit_free( commit );
2808 }
2809
2810 git_revwalk_free( walk );
2811 git_repository_free( repo );
2812 return snapshots;
2813}
2814
2815
2816std::vector<LOCAL_HISTORY_SNAPSHOT_INFO> LOCAL_HISTORY::GetSnapshots( const wxString& aProjectPath )
2817{
2818 return LoadSnapshots( aProjectPath );
2819}
2820
2821
2822wxString LOCAL_HISTORY::TreeFingerprint( const wxString& aProjectPath, const wxString& aHash,
2823 const wxString& aExtension )
2824{
2825 wxString hist = historyPath( aProjectPath );
2826 git_repository* repo = nullptr;
2827
2828 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
2829 return wxEmptyString;
2830
2831 git_oid oid;
2832 git_commit* commit = nullptr;
2833 git_tree* tree = nullptr;
2834
2835 if( git_oid_fromstr( &oid, aHash.mb_str().data() ) != 0 || git_commit_lookup( &commit, repo, &oid ) != 0 )
2836 {
2837 git_repository_free( repo );
2838 return wxEmptyString;
2839 }
2840
2841 if( git_commit_tree( &tree, commit ) != 0 )
2842 {
2843 git_commit_free( commit );
2844 git_repository_free( repo );
2845 return wxEmptyString;
2846 }
2847
2848 struct WALK_CTX
2849 {
2850 wxString ext;
2851 std::vector<wxString> entries;
2852 } ctx{ aExtension, {} };
2853
2854 auto collect = []( const char* aRoot, const git_tree_entry* aEntry, void* aPayload ) -> int
2855 {
2856 WALK_CTX* c = static_cast<WALK_CTX*>( aPayload );
2857
2858 if( git_tree_entry_type( aEntry ) != GIT_OBJECT_BLOB )
2859 return 0;
2860
2861 wxString name = wxString::FromUTF8( git_tree_entry_name( aEntry ) );
2862
2863 if( !name.EndsWith( c->ext ) )
2864 return 0;
2865
2866 wxString path = wxString::FromUTF8( aRoot ) + name;
2867 c->entries.push_back( path + wxS( ":" )
2868 + wxString::FromUTF8( git_oid_tostr_s( git_tree_entry_id( aEntry ) ) ) );
2869 return 0;
2870 };
2871
2872 git_tree_walk( tree, GIT_TREEWALK_PRE, collect, &ctx );
2873
2874 git_tree_free( tree );
2875 git_commit_free( commit );
2876 git_repository_free( repo );
2877
2878 std::sort( ctx.entries.begin(), ctx.entries.end() );
2879
2880 wxString fingerprint;
2881
2882 for( const wxString& entry : ctx.entries )
2883 fingerprint << entry << wxS( "|" );
2884
2885 return fingerprint;
2886}
2887
2888
2889bool LOCAL_HISTORY::ExtractAllFilesAtCommit( const wxString& aProjectPath, const wxString& aHash,
2890 const wxString& aDestDir, const std::vector<wxString>& aExtensions )
2891{
2892 wxString hist = historyPath( aProjectPath );
2893 git_repository* repo = nullptr;
2894
2895 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
2896 return false;
2897
2898 git_oid oid;
2899 git_commit* commit = nullptr;
2900 git_tree* tree = nullptr;
2901
2902 if( git_oid_fromstr( &oid, aHash.mb_str().data() ) != 0 || git_commit_lookup( &commit, repo, &oid ) != 0 )
2903 {
2904 git_repository_free( repo );
2905 return false;
2906 }
2907
2908 if( git_commit_tree( &tree, commit ) != 0 )
2909 {
2910 git_commit_free( commit );
2911 git_repository_free( repo );
2912 return false;
2913 }
2914
2915 struct WALK_CTX
2916 {
2917 git_repository* repo;
2918 wxString destDir;
2919 const std::vector<wxString>* extensions;
2920 bool ok;
2921 } ctx{ repo, aDestDir, &aExtensions, true };
2922
2923 // Non-capturing so it converts to the libgit2 C callback; state goes via payload.
2924 auto writeEntry = []( const char* aRoot, const git_tree_entry* aEntry, void* aPayload ) -> int
2925 {
2926 WALK_CTX* c = static_cast<WALK_CTX*>( aPayload );
2927
2928 if( git_tree_entry_type( aEntry ) != GIT_OBJECT_BLOB )
2929 return 0;
2930
2931 wxString name = wxString::FromUTF8( git_tree_entry_name( aEntry ) );
2932
2933 if( !c->extensions->empty() )
2934 {
2935 bool match = false;
2936
2937 for( const wxString& ext : *c->extensions )
2938 {
2939 if( name.EndsWith( ext ) )
2940 {
2941 match = true;
2942 break;
2943 }
2944 }
2945
2946 if( !match )
2947 return 0;
2948 }
2949
2950 wxString rel = wxString::FromUTF8( aRoot ) + name;
2951 wxFileName outFn( c->destDir + wxS( "/" ) + rel );
2952
2953 if( !wxFileName::Mkdir( outFn.GetPath(), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL ) )
2954 {
2955 c->ok = false;
2956 return 0;
2957 }
2958
2959 git_blob* blob = nullptr;
2960
2961 if( git_blob_lookup( &blob, c->repo, git_tree_entry_id( aEntry ) ) == 0 )
2962 {
2963 const void* data = git_blob_rawcontent( blob );
2964 const size_t size = static_cast<size_t>( git_blob_rawsize( blob ) );
2965 wxFFile out( outFn.GetFullPath(), wxS( "wb" ) );
2966
2967 if( !( data && out.IsOpened() && out.Write( data, size ) == size ) )
2968 c->ok = false;
2969
2970 git_blob_free( blob );
2971 }
2972 else
2973 {
2974 c->ok = false;
2975 }
2976
2977 return 0;
2978 };
2979
2980 git_tree_walk( tree, GIT_TREEWALK_PRE, writeEntry, &ctx );
2981
2982 git_tree_free( tree );
2983 git_commit_free( commit );
2984 git_repository_free( repo );
2985 return ctx.ok;
2986}
int index
const char * name
AUTO_BACKUP m_Backup
int ShowModal() override
Hybrid locking mechanism for local history git repositories.
git_repository * GetRepository()
Get the git repository handle (only valid if IsLocked() returns true).
void ReleaseRepository()
Release git repository and index handles early, but keep the file lock.
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.
std::vector< LOCAL_HISTORY_SNAPSHOT_INFO > LoadSnapshots(const wxString &aProjectPath)
bool EnforceSizeLimit(const wxString &aProjectPath, size_t aMaxBytes, PROGRESS_REPORTER *aReporter=nullptr)
Enforce total size limit by rebuilding trimmed history keeping newest commits whose cumulative unique...
bool TagSave(const wxString &aProjectPath, const wxString &aFileType)
Tag a manual save in the local history repository.
bool RunRegisteredSaversAndCommit(const wxString &aProjectPath, const wxString &aTitle, const wxString &aTagFileType=wxEmptyString)
Run all registered savers and, if any staged changes differ from HEAD, create a commit.
std::vector< std::pair< wxString, wxString > > FindStaleAutosaveFiles(const wxString &aProjectPath, const std::vector< wxString > &aExtensions) const
Enumerate autosave files newer than their corresponding source files for the project at aProjectPath,...
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.
bool commitInBackground(const wxString &aProjectPath, const wxString &aTitle, const std::vector< HISTORY_FILE_DATA > &aFileData, bool aIsManualSave)
Execute file writes and git commit on a background thread.
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
std::map< const void *, std::function< void(const wxString &, std::vector< HISTORY_FILE_DATA > &)> > m_savers
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...
void WaitForPendingSave()
Block until any pending background save completes.
void RegisterSaver(const void *aSaverObject, const std::function< void(const wxString &, std::vector< HISTORY_FILE_DATA > &)> &aSaver)
Register a saver callback invoked during autosave history commits.
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::atomic< bool > m_saveInProgress
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 RunRegisteredSaversAsAutosaveFiles(const wxString &aProjectPath)
Run all registered savers and write their output to autosave files instead of committing to the local...
bool CommitFullProjectSnapshot(const wxString &aProjectPath, const wxString &aTitle)
Commit a snapshot of the entire project directory (excluding the .history directory and ignored trans...
std::vector< LOCAL_HISTORY_SNAPSHOT_INFO > GetSnapshots(const wxString &aProjectPath)
Snapshots (commits) for the project, newest first.
wxString TreeFingerprint(const wxString &aProjectPath, const wxString &aHash, const wxString &aExtension)
Fingerprint of all files ending in aExtension recorded by commit aHash (sorted path:blob pairs).
bool ExtractAllFilesAtCommit(const wxString &aProjectPath, const wxString &aHash, const wxString &aDestDir, const std::vector< wxString > &aExtensions={})
Write files recorded at aHash into aDestDir, recreating the project's relative folder structure.
std::future< bool > m_pendingFuture
void UnregisterSaver(const void *aSaverObject)
Unregister a previously registered saver callback.
void RemoveAutosaveFiles(const wxString &aProjectPath) const
Remove every autosave file under the project at aProjectPath regardless of which source it shadowed.
static bool EnsurePathExists(const wxString &aPath, bool aPathToFile=false)
Attempts to create a given path if it does not exist.
Definition paths.cpp:518
virtual COMMON_SETTINGS * GetCommonSettings() const
Definition pgm_base.cpp:528
virtual SETTINGS_MANAGER & GetSettingsManager() const
Definition pgm_base.h:124
A progress reporter interface for use in multi-threaded environments.
virtual void Report(const wxString &aMessage)=0
Display aMessage in the progress bar dialog.
virtual void AdvancePhase()=0
Use the next available virtual zone of the dialog progress bar.
virtual void SetCurrentProgress(double aProgress)=0
Set the progress value to aProgress (0..1).
COMMON_SETTINGS * GetCommonSettings() const
Retrieve the common settings shared by all applications.
wxString GetAutosaveRootForProject(const PROJECT *aProject=nullptr) const
Resolve the autosave-files root for a project.
PROJECT * GetProjectForPath(const wxString &aProjectPath) const
Return the active project iff its path matches aProjectPath, else nullptr.
wxString GetLocalHistoryDirForPath(const wxString &aProjectPath) const
Resolve the local-history directory for a project given by its on-disk path.
@ INCREMENTAL
Git-based local history (default)
BACKUP_LOCATION
@ PROJECT_DIR
Inside the project directory (default)
This file is part of the common library.
#define KICAD_MESSAGE_DIALOG
Definition confirm.h:48
#define _(s)
static const std::string LockFileExtension
static const std::string ProjectFileExtension
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 bool compactRepository(git_repository *aRepo, PROGRESS_REPORTER *aReporter=nullptr)
static bool isRestoreProtectedEntry(const wxString &aName)
static std::vector< std::pair< wxString, wxString > > findAutosaveFilePairs(const wxString &aProjectPath)
static const wxString AUTOSAVE_PREFIX
static bool commitSnapshotForProject(const wxString &aProjectPath, const std::vector< wxString > &aFiles, const wxString &aTitle)
static size_t dirSizeRecursive(const wxString &path)
static bool copyTreeObjects(git_repository *aSrcRepo, git_odb *aSrcOdb, git_odb *aDstOdb, const git_oid *aTreeOid, std::set< git_oid, bool(*)(const git_oid &, const git_oid &)> &aCopied)
static bool isKiCadProjectFile(const wxFileName &aFile)
static wxString sourceForAutosaveFile(const wxString &aAutosavePath, const wxString &aProjectPath, const wxString &aAutosaveRoot, BACKUP_LOCATION aLocation)
static wxString resolveAutosaveDestination(const wxString &aAutosaveRoot, const wxString &aRelativePath, BACKUP_LOCATION aLocation)
static SNAPSHOT_COMMIT_RESULT commitSnapshotWithLock(git_repository *repo, git_index *index, const wxString &aHistoryPath, const wxString &aProjectPath, const std::vector< wxString > &aFiles, const wxString &aTitle)
SNAPSHOT_COMMIT_RESULT
static bool filesContentEqual(const wxString &aPathA, const wxString &aPathB)
static bool formatUsesIncrementalHistory()
static bool isProjectDirectory(const wxString &aProjectPath)
static void collectProjectFiles(const wxString &aProjectPath, std::vector< wxString > &aFiles)
static wxString joinHistoryDestination(const wxString &aHistoryRoot, const wxString &aRelativePath)
File locking utilities.
void Prettify(std::string &aSource, FORMAT_MODE aMode)
Pretty-prints s-expression text according to KiCad format rules.
bool AtomicWriteFile(const wxString &aTargetPath, const void *aData, size_t aSize, wxString *aError=nullptr)
Writes aData to aTargetPath via a sibling temp file, fsyncs the data and directory,...
PGM_BASE & Pgm()
The global program "get" accessor.
see class PGM_BASE
#define PROJECT_BACKUPS_DIR_SUFFIX
Project settings path will be <projectname> + this.
BACKUP_LOCATION location
Where backups, history, and autosave files live.
BACKUP_FORMAT format
Backup format (incremental git history vs zip archives)
Data produced by a registered saver on the UI thread, consumed by either the background local-history...
std::string path
IbisParser parser & reporter
VECTOR2I location
wxString result
Test unit parsing edge cases and error handling.
int delta
thread_pool & GetKiCadThreadPool()
Get a reference to the current thread pool.
wxLogTrace helper definitions.
Definition of file extensions used in Kicad.