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