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