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