KiCad PCB EDA Suite
Loading...
Searching...
No Matches
test_history_autosave.cpp
Go to the documentation of this file.
1/*
2 * This program source code file is part of KiCad, a free EDA CAD application.
3 *
4 * Copyright The KiCad Developers, see AUTHORS.txt for contributors.
5 *
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 3
9 * of the License, or (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
21
22#include <board.h>
23#include <local_history.h>
24#include <pgm_base.h>
25#include <project.h>
28
29#include <git2.h>
30
31#include <memory>
32#include <vector>
33
34#include <wx/datetime.h>
35#include <wx/dir.h>
36#include <wx/ffile.h>
37#include <wx/filefn.h>
38#include <wx/filename.h>
39#include <wx/stdpaths.h>
40
41
42namespace
43{
44// single_top handles libgit2 init in production; the QA harness does not, so tests driving
45// LOCAL_HISTORY must manage it themselves.
46struct LIBGIT2_SCOPE
47{
48 LIBGIT2_SCOPE() { git_libgit2_init(); }
49 ~LIBGIT2_SCOPE() { git_libgit2_shutdown(); }
50};
51
52
53struct SCOPED_BOOL_OVERRIDE
54{
55 explicit SCOPED_BOOL_OVERRIDE( bool& aFlag ) : m_flag( aFlag ), m_original( aFlag ) {}
56 ~SCOPED_BOOL_OVERRIDE() { m_flag = m_original; }
57
58 bool& m_flag;
59 bool m_original;
60};
61
62
63// Restore the backup location on destruction so a thrown BOOST_REQUIRE cannot leak the
64// override into later tests.
65struct SCOPED_BACKUP_LOCATION_OVERRIDE
66{
67 explicit SCOPED_BACKUP_LOCATION_OVERRIDE( BACKUP_LOCATION& aLocation ) :
68 m_location( aLocation ), m_original( aLocation )
69 {
70 }
71
72 ~SCOPED_BACKUP_LOCATION_OVERRIDE() { m_location = m_original; }
73
74 BACKUP_LOCATION& m_location;
75 BACKUP_LOCATION m_original;
76};
77
78
79struct SCOPED_BACKUP_FORMAT_OVERRIDE
80{
81 explicit SCOPED_BACKUP_FORMAT_OVERRIDE( BACKUP_FORMAT& aFormat ) :
82 m_format( aFormat ), m_original( aFormat )
83 {
84 }
85
86 ~SCOPED_BACKUP_FORMAT_OVERRIDE() { m_format = m_original; }
87
88 BACKUP_FORMAT& m_format;
89 BACKUP_FORMAT m_original;
90};
91
92
93// Load a project into the settings manager and unload it on destruction, keeping the global
94// active-project state isolated even when a test aborts partway through.
95struct SCOPED_PROJECT_LOAD
96{
97 SCOPED_PROJECT_LOAD( SETTINGS_MANAGER& aMgr, const wxString& aProjectFile ) : m_mgr( aMgr )
98 {
99 m_mgr.LoadProject( aProjectFile.ToStdString() );
100 }
101
102 ~SCOPED_PROJECT_LOAD() { m_mgr.UnloadProject( &m_mgr.Prj(), false ); }
103
104 SETTINGS_MANAGER& m_mgr;
105};
106
107
108// Recursively remove the directory on destruction so test failures (which throw out of
109// BOOST_REQUIRE) do not leak temp directories.
110struct SCOPED_TEMP_DIR
111{
112 explicit SCOPED_TEMP_DIR( const wxString& aPrefix )
113 {
114 wxString base = wxStandardPaths::Get().GetTempDir();
115 m_path = base + wxFileName::GetPathSeparator() + aPrefix
116 + wxString::Format( wxS( "_%lu_%ld" ),
117 static_cast<unsigned long>( ::wxGetProcessId() ),
118 static_cast<long>( wxDateTime::UNow().GetTicks() ) );
119 wxFileName::Mkdir( m_path, 0777, wxPATH_MKDIR_FULL );
120 }
121
122 ~SCOPED_TEMP_DIR()
123 {
124 if( !m_path.IsEmpty() && wxDirExists( m_path ) )
125 wxFileName::Rmdir( m_path, wxPATH_RMDIR_RECURSIVE );
126 }
127
128 const wxString& Path() const { return m_path; }
129
130 wxString m_path;
131};
132
133
134void writeTextFile( const wxString& aPath, const wxString& aContents )
135{
136 wxFFile f( aPath, wxT( "w" ) );
137 BOOST_REQUIRE( f.IsOpened() );
138 f.Write( aContents );
139 f.Close();
140}
141} // namespace
142
143
144BOOST_AUTO_TEST_SUITE( PcbHistoryAutosave )
145
146
147
156BOOST_AUTO_TEST_CASE( SaveToHistoryWithNullProjectDoesNotCrash )
157{
158 BOARD board;
159 std::vector<HISTORY_FILE_DATA> fileData;
160
161 BOOST_REQUIRE( board.GetProject() == nullptr );
162
163 BOOST_CHECK_NO_THROW( board.SaveToHistory( wxS( "/tmp/anywhere" ), fileData ) );
164 BOOST_CHECK( fileData.empty() );
165}
166
167
172BOOST_AUTO_TEST_CASE( SaveToHistoryUnsavedBoardProducesNothing )
173{
175
176 wxString tempDir = wxStandardPaths::Get().GetTempDir();
177 wxString projectPath = tempDir + wxFileName::GetPathSeparator() + wxS( "pcb_autosave.kicad_pro" );
178
179 mgr.LoadProject( projectPath.ToStdString() );
180
181 BOARD board;
182 board.SetProject( &mgr.Prj() );
183
184 std::vector<HISTORY_FILE_DATA> fileData;
185
186 BOOST_REQUIRE( board.GetFileName().IsEmpty() );
187 BOOST_CHECK_NO_THROW( board.SaveToHistory( mgr.Prj().GetProjectPath(), fileData ) );
188 BOOST_CHECK( fileData.empty() );
189
190 // Detach project before BOARD destruction so design settings ownership unwinds cleanly
191 board.ClearProject();
192 mgr.UnloadProject( &mgr.Prj(), false );
193}
194
195
203BOOST_AUTO_TEST_CASE( NoSnapshotWithoutProjectFile )
204{
205 LIBGIT2_SCOPE libgit;
206
207 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
208 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
209 backupEnabled = true;
210
211 SCOPED_TEMP_DIR notAProject( wxS( "kicad_qa_no_project" ) );
212 const wxString& path = notAProject.Path();
213
214 // Drop a board file in but no .kicad_pro - this mirrors saving a board to /tmp
215 // from standalone pcbnew.
216 wxString boardPath = path + wxFileName::GetPathSeparator() + wxS( "stray.kicad_pcb" );
217 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
218
219 LOCAL_HISTORY history;
220
221 BOOST_CHECK( !history.Init( path ) );
222 BOOST_CHECK( !history.CommitFullProjectSnapshot( path, wxS( "PCB Save" ) ) );
223 BOOST_CHECK( !history.TagSave( path, wxS( "pcb" ) ) );
224
225 // No .history directory should have been created.
226 wxString historyDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
227 BOOST_CHECK( !wxDirExists( historyDir ) );
228}
229
230
238BOOST_AUTO_TEST_CASE( CommitFullProjectSnapshotHandlesSubdirectories )
239{
240 LIBGIT2_SCOPE libgit;
241
242 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
243 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
244 backupEnabled = true;
245
246 SCOPED_TEMP_DIR project( wxS( "kicad_qa_subdirs" ) );
247 const wxString& path = project.Path();
248
249 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "subdirs.kicad_pro" ), wxS( "{}\n" ) );
250 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "subdirs.kicad_pcb" ),
251 wxS( "(kicad_pcb (version 20240108))\n" ) );
252
253 // Create a subdirectory whose contents will likely be collected before files at the
254 // project root, exercising the subdirectory-first iteration order.
255 wxString subDir = path + wxFileName::GetPathSeparator() + wxS( "libs" );
256 BOOST_REQUIRE( wxFileName::Mkdir( subDir, 0777, wxPATH_MKDIR_FULL ) );
257 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "fp.kicad_mod" ),
258 wxS( "(footprint test)\n" ) );
259
260 LOCAL_HISTORY history;
261 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Initial" ) ) );
262
263 // History must have been created at the project root, not at the subdirectory.
264 wxString historyDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
265 BOOST_CHECK( wxDirExists( historyDir ) );
266 BOOST_CHECK( !wxDirExists( subDir + wxFileName::GetPathSeparator() + wxS( ".history" ) ) );
267
268 wxString headBefore = history.GetHeadHash( path );
269 BOOST_REQUIRE( !headBefore.IsEmpty() );
270
271 // Mutate a file in the subdirectory to ensure the next snapshot has work to do.
272 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "fp.kicad_mod" ),
273 wxS( "(footprint test (modified))\n" ) );
274
275 // CommitFullProjectSnapshot must commit using the project root, not derive a wrong
276 // root from the first file in the recursive collection.
277 BOOST_CHECK( history.CommitFullProjectSnapshot( path, wxS( "PCB Save" ) ) );
278
279 wxString headAfter = history.GetHeadHash( path );
280 BOOST_REQUIRE( !headAfter.IsEmpty() );
281 BOOST_CHECK( headBefore != headAfter );
282}
283
284
285// Regression test for https://gitlab.com/kicad/code/kicad/-/issues/24016
286BOOST_AUTO_TEST_CASE( RestoreCommitPreservesZipBackupsDirectory )
287{
288 LIBGIT2_SCOPE libgit;
289
290 // LOCAL_HISTORY early-exits when backups are disabled.
291 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
292 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
293 backupEnabled = true;
294
295 SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_issue24016" ) );
296 const wxString& projectPath = tempProject.Path();
297
298 wxString boardPath =
299 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016.kicad_pcb" );
300 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
301
302 // LOCAL_HISTORY refuses to operate on directories without a project file, so seed
303 // the fixture with a minimal one to mirror a real KiCad project layout.
304 wxString projectFile =
305 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016.kicad_pro" );
306 writeTextFile( projectFile, wxS( "{}\n" ) );
307
308 LOCAL_HISTORY history;
309 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
310
311 wxString headHash = history.GetHeadHash( projectPath );
312 BOOST_REQUIRE( !headHash.IsEmpty() );
313
314 // Mirror SETTINGS_MANAGER::BackupProject output: a sibling "<name>-backups" directory
315 // containing one or more .zip archives.
316 wxString backupsDir =
317 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016-backups" );
318 BOOST_REQUIRE( wxFileName::Mkdir( backupsDir, 0777, wxPATH_MKDIR_FULL ) );
319
320 wxString zipPath = backupsDir + wxFileName::GetPathSeparator()
321 + wxS( "issue24016-2026-04-22_120000.zip" );
322 writeTextFile( zipPath, wxS( "pretend-zip-contents" ) );
323
324 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
325
326 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
327
328 BOOST_CHECK_MESSAGE( wxDirExists( backupsDir ),
329 "zip backups directory must survive RestoreCommit" );
330 BOOST_CHECK_MESSAGE( wxFileExists( zipPath ),
331 ".zip archive inside the backups directory must survive RestoreCommit" );
332}
333
334
348BOOST_AUTO_TEST_CASE( RestoreCommitPreservesNestedProject )
349{
350 LIBGIT2_SCOPE libgit;
351
352 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
353 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
354 backupEnabled = true;
355
356 SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_nested_project" ) );
357 const wxString& projectPath = tempProject.Path();
358
359 // Parent project (projectA): minimal .kicad_pro plus a board file.
360 wxString parentPro = projectPath + wxFileName::GetPathSeparator() + wxS( "projectA.kicad_pro" );
361 wxString parentPcb = projectPath + wxFileName::GetPathSeparator() + wxS( "projectA.kicad_pcb" );
362 writeTextFile( parentPro, wxS( "{}\n" ) );
363 writeTextFile( parentPcb, wxS( "(kicad_pcb (version 20240108))\n" ) );
364
365 LOCAL_HISTORY history;
366 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
367
368 wxString headHash = history.GetHeadHash( projectPath );
369 BOOST_REQUIRE( !headHash.IsEmpty() );
370
371 // Now drop a nested project under projectA/. The user's scenario was that this nested
372 // project was added AFTER the parent's snapshot was committed - so its files are not in
373 // the restored commit, and a naive restore would propose them for deletion.
374 wxString nestedDir = projectPath + wxFileName::GetPathSeparator() + wxS( "projectB" );
375 BOOST_REQUIRE( wxFileName::Mkdir( nestedDir, 0777, wxPATH_MKDIR_FULL ) );
376
377 wxString nestedPro = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_pro" );
378 wxString nestedPcb = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_pcb" );
379 wxString nestedSch = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_sch" );
380 writeTextFile( nestedPro, wxS( "{ \"nested\": true }\n" ) );
381 writeTextFile( nestedPcb, wxS( "(kicad_pcb (version 20240108) (nested yes))\n" ) );
382 writeTextFile( nestedSch, wxS( "(kicad_sch (version 20240108))\n" ) );
383
384 // Modify the parent board so the restore actually has work to do.
385 writeTextFile( parentPcb, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
386
387 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
388
389 // The nested project's directory and every one of its files MUST survive the restore.
390 BOOST_CHECK_MESSAGE( wxDirExists( nestedDir ),
391 "nested project directory must survive RestoreCommit" );
392 BOOST_CHECK_MESSAGE( wxFileExists( nestedPro ),
393 "nested .kicad_pro must survive RestoreCommit" );
394 BOOST_CHECK_MESSAGE( wxFileExists( nestedPcb ),
395 "nested .kicad_pcb must survive RestoreCommit" );
396 BOOST_CHECK_MESSAGE( wxFileExists( nestedSch ),
397 "nested .kicad_sch must survive RestoreCommit" );
398
399 // Parent project files were correctly restored.
400 BOOST_CHECK( wxFileExists( parentPro ) );
401 BOOST_CHECK( wxFileExists( parentPcb ) );
402}
403
404
410BOOST_AUTO_TEST_CASE( RestoreCommitRetainsTimestampedBackup )
411{
412 LIBGIT2_SCOPE libgit;
413
414 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
415 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
416 backupEnabled = true;
417
418 SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_retained_backup" ) );
419 const wxString& projectPath = tempProject.Path();
420
421 wxString boardPath = projectPath + wxFileName::GetPathSeparator() + wxS( "rb.kicad_pcb" );
422 wxString projectFile = projectPath + wxFileName::GetPathSeparator() + wxS( "rb.kicad_pro" );
423 writeTextFile( projectFile, wxS( "{}\n" ) );
424 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
425
426 LOCAL_HISTORY history;
427 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
428
429 wxString headHash = history.GetHeadHash( projectPath );
430 BOOST_REQUIRE( !headHash.IsEmpty() );
431
432 // Mutate the board so restore has work to do (and produces a backup).
433 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
434
435 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
436
437 // Backups land at a SIBLING path (aProjectPath + "_restore_backup_<ts>"), so look in
438 // the parent directory of the project. Same convention as the legacy "_restore_backup".
439 wxString parentDir = wxFileName( projectPath ).GetPath();
440 wxString leafPrefix = wxFileName( projectPath ).GetFullName() + wxS( "_restore_backup_" );
441
442 wxDir dir( parentDir );
443 BOOST_REQUIRE( dir.IsOpened() );
444
445 wxString retainedBackup;
446 bool foundLegacyBackup = false;
447
448 wxString name;
449 for( bool cont = dir.GetFirst( &name, wxEmptyString, wxDIR_DIRS ); cont;
450 cont = dir.GetNext( &name ) )
451 {
452 if( name.StartsWith( leafPrefix ) )
453 {
454 retainedBackup = parentDir + wxFileName::GetPathSeparator() + name;
455
456 // No colons - Windows path-safe.
457 BOOST_CHECK_MESSAGE( name.Find( ':' ) == wxNOT_FOUND,
458 "retained backup directory name must not contain ':' "
459 "(Windows-illegal in path components)" );
460 }
461 else if( name == wxFileName( projectPath ).GetFullName() + wxS( "_restore_backup" ) )
462 {
463 foundLegacyBackup = true;
464 }
465 }
466
467 BOOST_CHECK_MESSAGE(
468 !retainedBackup.IsEmpty(),
469 "RestoreCommit must retain a timestamped _restore_backup_<ts>/ sibling directory" );
470 BOOST_CHECK_MESSAGE(
471 !foundLegacyBackup,
472 "RestoreCommit must not leave the legacy non-timestamped _restore_backup/ behind" );
473
474 // Clean up the retained backup so the test does not leak files into /tmp.
475 if( !retainedBackup.IsEmpty() && wxDirExists( retainedBackup ) )
476 wxFileName::Rmdir( retainedBackup, wxPATH_RMDIR_RECURSIVE );
477}
478
479
483BOOST_AUTO_TEST_CASE( CommitFullProjectSnapshotExcludesNonKiCadFiles )
484{
485 LIBGIT2_SCOPE libgit;
486
487 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
488 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
489 backupEnabled = true;
490
491 SCOPED_TEMP_DIR project( wxS( "kicad_qa_privacy" ) );
492 const wxString& path = project.Path();
493
494 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
495 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
496 wxS( "(kicad_pcb (version 20240108))\n" ) );
497 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_sch" ),
498 wxS( "(kicad_sch (version 20240108))\n" ) );
499
500 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "passwords.txt" ), wxS( "secret\n" ) );
501 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "datasheet.pdf" ), wxS( "fake pdf bytes\n" ) );
502 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "notes.md" ), wxS( "personal notes\n" ) );
503
504 wxString subDir = path + wxFileName::GetPathSeparator() + wxS( "docs" );
505 BOOST_REQUIRE( wxFileName::Mkdir( subDir, 0777, wxPATH_MKDIR_FULL ) );
506 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "manual.txt" ), wxS( "irrelevant\n" ) );
507
508 LOCAL_HISTORY history;
509 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Close" ) ) );
510
511 wxString hist = path + wxFileName::GetPathSeparator() + wxS( ".history" );
512 git_repository* repo = nullptr;
513 BOOST_REQUIRE_EQUAL( git_repository_open( &repo, hist.mb_str().data() ), 0 );
514
515 git_oid head_oid;
516 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &head_oid, repo, "HEAD" ), 0 );
517
518 git_commit* head = nullptr;
519 BOOST_REQUIRE_EQUAL( git_commit_lookup( &head, repo, &head_oid ), 0 );
520
521 git_tree* tree = nullptr;
522 BOOST_REQUIRE_EQUAL( git_commit_tree( &tree, head ), 0 );
523
524 std::vector<std::string> committedPaths;
525 git_tree_walk(
526 tree, GIT_TREEWALK_PRE,
527 []( const char* root, const git_tree_entry* entry, void* payload ) -> int
528 {
529 auto* paths = static_cast<std::vector<std::string>*>( payload );
530
531 if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
532 paths->push_back( std::string( root ) + git_tree_entry_name( entry ) );
533
534 return 0;
535 },
536 &committedPaths );
537
538 git_tree_free( tree );
539 git_commit_free( head );
540 git_repository_free( repo );
541
542 auto contains = [&]( const std::string& s )
543 {
544 return std::find( committedPaths.begin(), committedPaths.end(), s ) != committedPaths.end();
545 };
546
547 BOOST_CHECK_MESSAGE( contains( "p.kicad_pro" ), "kicad_pro must be committed" );
548 BOOST_CHECK_MESSAGE( contains( "p.kicad_pcb" ), "kicad_pcb must be committed" );
549 BOOST_CHECK_MESSAGE( contains( "p.kicad_sch" ), "kicad_sch must be committed" );
550
551 BOOST_CHECK_MESSAGE( !contains( "passwords.txt" ), "passwords.txt must NOT appear in history" );
552 BOOST_CHECK_MESSAGE( !contains( "datasheet.pdf" ), "datasheet.pdf must NOT appear in history" );
553 BOOST_CHECK_MESSAGE( !contains( "notes.md" ), "notes.md must NOT appear in history" );
554 BOOST_CHECK_MESSAGE( !contains( "docs/manual.txt" ), "subdirectory user content must NOT appear in history" );
555}
556
557
562BOOST_AUTO_TEST_CASE( FirstAutosaveSkipsCommitWhenStagedMatchesDisk )
563{
564 LIBGIT2_SCOPE libgit;
565
566 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
567 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
568 backupEnabled = true;
569
570 SCOPED_TEMP_DIR project( wxS( "kicad_qa_first_idle_autosave" ) );
571 const wxString& path = project.Path();
572
573 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
574 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
575 wxS( "(kicad_pcb (version 20240108))\n" ) );
576
577 LOCAL_HISTORY history;
578
579 // Mutating this between calls simulates the user editing the in-memory document.
580 std::string inMemoryContent = "(kicad_pcb (version 20240108))\n";
581
582 auto saver = [&inMemoryContent]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
583 {
584 HISTORY_FILE_DATA entry;
585 entry.relativePath = wxS( "p.kicad_pcb" );
586 entry.content = inMemoryContent;
587 aFileData.push_back( std::move( entry ) );
588 };
589
590 history.RegisterSaver( &history, saver );
591
592 // Saver output matches disk, must skip (autosave path: empty tagFileType).
593 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
594 history.WaitForPendingSave();
595
596 wxString histDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
597 BOOST_CHECK( wxDirExists( histDir ) );
598
599 wxString head = history.GetHeadHash( path );
600 BOOST_CHECK_MESSAGE( head.IsEmpty(), "no untagged HEAD should exist after an idle first save" );
601
602 // Real edit, must commit.
603 inMemoryContent = "(kicad_pcb (version 20240108) (edited yes))\n";
604
605 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
606 history.WaitForPendingSave();
607
608 head = history.GetHeadHash( path );
609 BOOST_CHECK_MESSAGE( !head.IsEmpty(), "in-memory edits diverging from disk must produce a commit" );
610
611 history.UnregisterSaver( &history );
612}
613
614
621BOOST_AUTO_TEST_CASE( SaverSkippedAfterOwningDocumentDestroyed )
622{
623 LIBGIT2_SCOPE libgit;
624
625 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
626 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
627 backupEnabled = true;
628
629 SCOPED_TEMP_DIR project( wxS( "kicad_qa_saver_lifetime" ) );
630 const wxString& path = project.Path();
631 const wxString sep = wxFileName::GetPathSeparator();
632
633 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
634 writeTextFile( path + sep + wxS( "p.kicad_pcb" ), wxS( "(kicad_pcb (version 20240108))\n" ) );
635
636 LOCAL_HISTORY history;
637
638 // The saver only touches this heap counter, so it stays safe to invoke after the board is gone;
639 // gating it on the board's token is the behaviour under test.
640 auto runCount = std::make_shared<int>( 0 );
641 std::unique_ptr<BOARD> board = std::make_unique<BOARD>();
642
643 history.RegisterSaver( board.get(),
644 [runCount]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
645 {
646 ++( *runCount );
647
648 HISTORY_FILE_DATA entry;
649 entry.relativePath = wxS( "p.kicad_pcb" );
650 entry.content = "(kicad_pcb (version 20240108) (edited yes))\n";
651 aFileData.push_back( std::move( entry ) );
652 },
653 board->GetHistoryLifetimeToken() );
654
655 // Positive control: while the board is alive the saver runs.
656 history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString );
657 history.WaitForPendingSave();
658 BOOST_CHECK_EQUAL( *runCount, 1 );
659
660 // Destroy the document; its expired token must make both runners skip and drop the saver.
661 board.reset();
662
663 history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString );
664 history.WaitForPendingSave();
665 BOOST_CHECK_MESSAGE( *runCount == 1, "commit runner invoked a saver whose board was destroyed" );
666
668 BOOST_CHECK_MESSAGE( *runCount == 1, "autosave-file runner invoked a saver whose board was destroyed" );
669}
670
671
677BOOST_AUTO_TEST_CASE( FirstManualSaveAlwaysCommitsOnFreshProject )
678{
679 LIBGIT2_SCOPE libgit;
680
681 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
682 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
683 backupEnabled = true;
684
685 SCOPED_TEMP_DIR project( wxS( "kicad_qa_first_manual_save" ) );
686 const wxString& path = project.Path();
687
688 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
689 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
690 wxS( "(kicad_pcb (version 20240108))\n" ) );
691
692 LOCAL_HISTORY history;
693
694 std::string inMemoryContent = "(kicad_pcb (version 20240108))\n";
695
696 auto saver = [&inMemoryContent]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
697 {
698 HISTORY_FILE_DATA entry;
699 entry.relativePath = wxS( "p.kicad_pcb" );
700 entry.content = inMemoryContent;
701 aFileData.push_back( std::move( entry ) );
702 };
703
704 history.RegisterSaver( &history, saver );
705
706 // Manual save (non-empty tagFileType) on fresh project: commit even when staged matches disk.
707 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Manual Save" ), wxS( "pcb" ) ) );
708
709 wxString head = history.GetHeadHash( path );
710 BOOST_CHECK_MESSAGE( !head.IsEmpty(), "manual save on a fresh project must commit even when staged matches disk" );
711
712 history.UnregisterSaver( &history );
713}
714
715
716// With backups enabled but the format set to Zip, autosave uses legacy recovery files, so the
717// incremental git-commit path must be a no-op rather than extending a history the user switched
718// off (issue 24773).
719BOOST_AUTO_TEST_CASE( ZipFormatSkipsIncrementalAutosave )
720{
721 LIBGIT2_SCOPE libgit;
722
723 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
724 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
725 backupEnabled = true;
726
728 SCOPED_BACKUP_FORMAT_OVERRIDE restoreFormat( format );
729 format = BACKUP_FORMAT::ZIP;
730
731 SCOPED_TEMP_DIR project( wxS( "kicad_qa_zip_skips_incremental" ) );
732 const wxString& path = project.Path();
733
734 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
735 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
736 wxS( "(kicad_pcb (version 20240108))\n" ) );
737
738 LOCAL_HISTORY history;
739
740 auto saver = []( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
741 {
742 HISTORY_FILE_DATA entry;
743 entry.relativePath = wxS( "p.kicad_pcb" );
744 entry.content = "(kicad_pcb (version 20240108) (edited yes))\n";
745 aFileData.push_back( std::move( entry ) );
746 };
747
748 history.RegisterSaver( &history, saver );
749
750 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
751 history.WaitForPendingSave();
752
753 BOOST_CHECK_MESSAGE( history.GetHeadHash( path ).IsEmpty(),
754 "zip backup format must not create incremental autosave commits" );
755
756 history.UnregisterSaver( &history );
757}
758
759
760// The Zip backup format must reliably write legacy recovery files on autosave so a crash does
761// not lose work between manual saves (issue 24773).
762BOOST_AUTO_TEST_CASE( ZipFormatWritesRecoveryFiles )
763{
764 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
765 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
766 backupEnabled = true;
767
769 SCOPED_BACKUP_FORMAT_OVERRIDE restoreFormat( format );
770 format = BACKUP_FORMAT::ZIP;
771
773 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
775
776 SCOPED_TEMP_DIR project( wxS( "kicad_qa_zip_recovery_files" ) );
777 const wxString& path = project.Path();
778 const wxString sep = wxFileName::GetPathSeparator();
779
780 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
781
783 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
784
785 LOCAL_HISTORY history;
786
787 auto saver = []( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
788 {
789 HISTORY_FILE_DATA entry;
790 entry.relativePath = wxS( "p.kicad_pcb" );
791 entry.content = "(kicad_pcb (version 20240108) (edited yes))\n";
792 aFileData.push_back( std::move( entry ) );
793 };
794
795 history.RegisterSaver( &history, saver );
796
798
799 wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
800 BOOST_CHECK_MESSAGE( wxFileExists( autosavePath ),
801 "zip backup format must write autosave recovery files" );
802
803 history.UnregisterSaver( &history );
804}
805
806
807// A content-identical autosave with a newer mtime (cloud-sync touch) must not be flagged
808// stale, or recovery prompts fire on every open with nothing to restore (issue 24126).
809BOOST_AUTO_TEST_CASE( CloudSyncTouchedAutosaveFalselyFlaggedStale )
810{
811 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
812 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
813 backupEnabled = true;
814
816 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
818
819 SCOPED_TEMP_DIR project( wxS( "kicad_qa_cloudsync_autosave" ) );
820 const wxString& path = project.Path();
821 const wxString sep = wxFileName::GetPathSeparator();
822
823 // FindStaleAutosaveFiles resolves the autosave root through the active project, so it must
824 // be loaded; the .kicad_pro must exist on disk first for LoadProject to succeed.
825 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
826
828 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
829
830 const wxString sourcePath = path + sep + wxS( "p.kicad_pcb" );
831 const wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
832 const wxString boardContent = wxS( "(kicad_pcb (version 20240108))\n" );
833
834 // Byte-identical source and "_autosave-" companion, mirroring a clean save.
835 writeTextFile( sourcePath, boardContent );
836 writeTextFile( autosavePath, boardContent );
837
838 LOCAL_HISTORY history;
839
840 // Cloud-sync touch gives the identical autosave a strictly newer mtime.
841 wxDateTime srcMtime = wxFileName( sourcePath ).GetModificationTime();
842 wxDateTime newerMtime = srcMtime + wxTimeSpan::Seconds( 60 );
843 BOOST_REQUIRE( wxFileName( autosavePath ).SetTimes( &newerMtime, &newerMtime, nullptr ) );
844
845 std::vector<wxString> exts{ wxS( "kicad_pcb" ) };
846 auto stale = history.FindStaleAutosaveFiles( path, exts );
847
848 BOOST_CHECK_MESSAGE( stale.empty(),
849 "Content-identical autosave with newer mtime was flagged stale, "
850 "triggering a spurious recovery prompt (issue 24126)" );
851}
852
853
854// Counterpart to the above: an autosave whose content genuinely differs (a real unsaved edit)
855// must still be flagged stale so recovery continues to fire.
856BOOST_AUTO_TEST_CASE( DivergentAutosaveStillFlaggedStale )
857{
858 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
859 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
860 backupEnabled = true;
861
863 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
865
866 SCOPED_TEMP_DIR project( wxS( "kicad_qa_divergent_autosave" ) );
867 const wxString& path = project.Path();
868 const wxString sep = wxFileName::GetPathSeparator();
869
870 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
871
873 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
874
875 const wxString sourcePath = path + sep + wxS( "p.kicad_pcb" );
876 const wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
877
878 // Autosave content diverges from the source, a genuine unsaved edit to recover.
879 writeTextFile( sourcePath, wxS( "(kicad_pcb (version 20240108))\n" ) );
880 writeTextFile( autosavePath, wxS( "(kicad_pcb (version 20240108) (edited yes))\n" ) );
881
882 LOCAL_HISTORY history;
883
884 wxDateTime srcMtime = wxFileName( sourcePath ).GetModificationTime();
885 wxDateTime newerMtime = srcMtime + wxTimeSpan::Seconds( 60 );
886 BOOST_REQUIRE( wxFileName( autosavePath ).SetTimes( &newerMtime, &newerMtime, nullptr ) );
887
888 std::vector<wxString> exts{ wxS( "kicad_pcb" ) };
889 auto stale = history.FindStaleAutosaveFiles( path, exts );
890
891 BOOST_CHECK_MESSAGE( stale.size() == 1,
892 "Genuinely divergent autosave must still be flagged stale for recovery" );
893}
894
895
const char * name
Information pertinent to a Pcbnew printed circuit board.
Definition board.h:373
void SaveToHistory(const wxString &aProjectPath, std::vector< HISTORY_FILE_DATA > &aFileData)
Serialize board into HISTORY_FILE_DATA for non-blocking history commit.
Definition board.cpp:4198
void SetProject(PROJECT *aProject, bool aReferenceOnly=false)
Link a board to a given project.
Definition board.cpp:212
const wxString & GetFileName() const
Definition board.h:410
void ClearProject()
Definition board.cpp:253
PROJECT * GetProject() const
Definition board.h:662
AUTO_BACKUP m_Backup
Simple local history manager built on libgit2.
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.
void WaitForPendingSave()
Block until any pending background save completes.
bool RestoreCommit(const wxString &aProjectPath, const wxString &aHash, wxWindow *aParent=nullptr, bool aConfirm=true)
Restore the project files to the state recorded by the given commit hash.
bool Init(const wxString &aProjectPath)
Initialize the local history repository for the given project path.
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...
void RegisterSaver(const void *aSaverObject, const std::function< void(const wxString &, std::vector< HISTORY_FILE_DATA > &)> &aSaver, const std::weak_ptr< void > &aLifetime={})
Register a saver callback invoked during autosave history commits.
void UnregisterSaver(const void *aSaverObject)
Unregister a previously registered saver callback.
virtual COMMON_SETTINGS * GetCommonSettings() const
Definition pgm_base.cpp:562
virtual SETTINGS_MANAGER & GetSettingsManager() const
Definition pgm_base.h:124
virtual const wxString GetProjectPath() const
Return the full path of the project.
Definition project.cpp:183
bool LoadProject(const wxString &aFullPath, bool aSetActive=true)
Load a project or sets up a new project with a specified path.
bool UnloadProject(PROJECT *aProject, bool aSave=true)
Save, unload and unregister the given PROJECT.
PROJECT & Prj() const
A helper while we are not MDI-capable – return the one and only project.
BACKUP_FORMAT
@ ZIP
Zip archive snapshots; autosave uses recovery files.
BACKUP_LOCATION
@ PROJECT_DIR
Inside the project directory (default)
PGM_BASE & Pgm()
The global program "get" accessor.
see class PGM_BASE
BACKUP_LOCATION location
Where backups, history, and autosave files live.
BACKUP_FORMAT format
Backup format (incremental git history vs zip archives)
bool enabled
Automatically back up the project when files are saved.
Data produced by a registered saver on the UI thread, consumed by either the background local-history...
std::string content
Serialized content (mutually exclusive with sourcePath)
wxString relativePath
Destination path relative to the project root.
BOOST_AUTO_TEST_CASE(HorizontalAlignment)
BOOST_AUTO_TEST_SUITE(CadstarPartParser)
BOOST_REQUIRE(intersection.has_value()==c.ExpectedIntersection.has_value())
BOOST_AUTO_TEST_SUITE_END()
BOOST_AUTO_TEST_CASE(SaveToHistoryWithNullProjectDoesNotCrash)
Regression test for https://gitlab.com/kicad/code/kicad/-/issues/23737.
std::string path
VECTOR2I location
BOOST_CHECK_EQUAL(result, "25.4")