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#include <qa_utils/file_utils.h>
22
23#include <board.h>
24#include <local_history.h>
25#include <pgm_base.h>
26#include <project.h>
29
30#include <git2.h>
31
32#include <memory>
33#include <vector>
34
35#include <wx/datetime.h>
36#include <wx/dir.h>
37#include <wx/ffile.h>
38#include <wx/filefn.h>
39#include <wx/filename.h>
40#include <wx/stdpaths.h>
41
42
43namespace
44{
45// single_top handles libgit2 init in production; the QA harness does not, so tests driving
46// LOCAL_HISTORY must manage it themselves.
47struct LIBGIT2_SCOPE
48{
49 LIBGIT2_SCOPE() { git_libgit2_init(); }
50 ~LIBGIT2_SCOPE() { git_libgit2_shutdown(); }
51};
52
53
54struct SCOPED_BOOL_OVERRIDE
55{
56 explicit SCOPED_BOOL_OVERRIDE( bool& aFlag ) : m_flag( aFlag ), m_original( aFlag ) {}
57 ~SCOPED_BOOL_OVERRIDE() { m_flag = m_original; }
58
59 bool& m_flag;
60 bool m_original;
61};
62
63
64// Restore the backup location on destruction so a thrown BOOST_REQUIRE cannot leak the
65// override into later tests.
66struct SCOPED_BACKUP_LOCATION_OVERRIDE
67{
68 explicit SCOPED_BACKUP_LOCATION_OVERRIDE( BACKUP_LOCATION& aLocation ) :
69 m_location( aLocation ), m_original( aLocation )
70 {
71 }
72
73 ~SCOPED_BACKUP_LOCATION_OVERRIDE() { m_location = m_original; }
74
75 BACKUP_LOCATION& m_location;
76 BACKUP_LOCATION m_original;
77};
78
79
80struct SCOPED_BACKUP_FORMAT_OVERRIDE
81{
82 explicit SCOPED_BACKUP_FORMAT_OVERRIDE( BACKUP_FORMAT& aFormat ) :
83 m_format( aFormat ), m_original( aFormat )
84 {
85 }
86
87 ~SCOPED_BACKUP_FORMAT_OVERRIDE() { m_format = m_original; }
88
89 BACKUP_FORMAT& m_format;
90 BACKUP_FORMAT m_original;
91};
92
93
94// Load a project into the settings manager and unload it on destruction, keeping the global
95// active-project state isolated even when a test aborts partway through.
96struct SCOPED_PROJECT_LOAD
97{
98 SCOPED_PROJECT_LOAD( SETTINGS_MANAGER& aMgr, const wxString& aProjectFile ) : m_mgr( aMgr )
99 {
100 m_mgr.LoadProject( aProjectFile.ToStdString() );
101 }
102
103 ~SCOPED_PROJECT_LOAD() { m_mgr.UnloadProject( &m_mgr.Prj(), false ); }
104
105 SETTINGS_MANAGER& m_mgr;
106};
107
108
109void writeTextFile( const wxString& aPath, const wxString& aContents )
110{
111 wxFFile f( aPath, wxT( "w" ) );
112 BOOST_REQUIRE( f.IsOpened() );
113 f.Write( aContents );
114 f.Close();
115}
116} // namespace
117
118
119BOOST_AUTO_TEST_SUITE( PcbHistoryAutosave )
120
121
122
131BOOST_AUTO_TEST_CASE( SaveToHistoryWithNullProjectDoesNotCrash )
132{
133 BOARD board;
134 std::vector<HISTORY_FILE_DATA> fileData;
135
136 BOOST_REQUIRE( board.GetProject() == nullptr );
137
138 BOOST_CHECK_NO_THROW( board.SaveToHistory( wxS( "/tmp/anywhere" ), fileData ) );
139 BOOST_CHECK( fileData.empty() );
140}
141
142
147BOOST_AUTO_TEST_CASE( SaveToHistoryUnsavedBoardProducesNothing )
148{
150
151 wxString tempDir = wxStandardPaths::Get().GetTempDir();
152 wxString projectPath = tempDir + wxFileName::GetPathSeparator() + wxS( "pcb_autosave.kicad_pro" );
153
154 mgr.LoadProject( projectPath.ToStdString() );
155
156 BOARD board;
157 board.SetProject( &mgr.Prj() );
158
159 std::vector<HISTORY_FILE_DATA> fileData;
160
161 BOOST_REQUIRE( board.GetFileName().IsEmpty() );
162 BOOST_CHECK_NO_THROW( board.SaveToHistory( mgr.Prj().GetProjectPath(), fileData ) );
163 BOOST_CHECK( fileData.empty() );
164
165 // Detach project before BOARD destruction so design settings ownership unwinds cleanly
166 board.ClearProject();
167 mgr.UnloadProject( &mgr.Prj(), false );
168}
169
170
178BOOST_AUTO_TEST_CASE( NoSnapshotWithoutProjectFile )
179{
180 LIBGIT2_SCOPE libgit;
181
182 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
183 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
184 backupEnabled = true;
185
186 KI_TEST::SCOPED_TEMP_DIR notAProject( wxS( "kicad_qa_no_project" ) );
187 const wxString& path = notAProject.PathStr();
188
189 // Drop a board file in but no .kicad_pro - this mirrors saving a board to /tmp
190 // from standalone pcbnew.
191 wxString boardPath = path + wxFileName::GetPathSeparator() + wxS( "stray.kicad_pcb" );
192 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
193
194 LOCAL_HISTORY history;
195
196 BOOST_CHECK( !history.Init( path ) );
197 BOOST_CHECK( !history.CommitFullProjectSnapshot( path, wxS( "PCB Save" ) ) );
198 BOOST_CHECK( !history.TagSave( path, wxS( "pcb" ) ) );
199
200 // No .history directory should have been created.
201 wxString historyDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
202 BOOST_CHECK( !wxDirExists( historyDir ) );
203}
204
205
213BOOST_AUTO_TEST_CASE( CommitFullProjectSnapshotHandlesSubdirectories )
214{
215 LIBGIT2_SCOPE libgit;
216
217 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
218 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
219 backupEnabled = true;
220
221 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_subdirs" ) );
222 const wxString& path = project.PathStr();
223
224 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "subdirs.kicad_pro" ), wxS( "{}\n" ) );
225 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "subdirs.kicad_pcb" ),
226 wxS( "(kicad_pcb (version 20240108))\n" ) );
227
228 // Create a subdirectory whose contents will likely be collected before files at the
229 // project root, exercising the subdirectory-first iteration order.
230 wxString subDir = path + wxFileName::GetPathSeparator() + wxS( "libs" );
231 BOOST_REQUIRE( wxFileName::Mkdir( subDir, 0777, wxPATH_MKDIR_FULL ) );
232 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "fp.kicad_mod" ),
233 wxS( "(footprint test)\n" ) );
234
235 LOCAL_HISTORY history;
236 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Initial" ) ) );
237
238 // History must have been created at the project root, not at the subdirectory.
239 wxString historyDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
240 BOOST_CHECK( wxDirExists( historyDir ) );
241 BOOST_CHECK( !wxDirExists( subDir + wxFileName::GetPathSeparator() + wxS( ".history" ) ) );
242
243 wxString headBefore = history.GetHeadHash( path );
244 BOOST_REQUIRE( !headBefore.IsEmpty() );
245
246 // Mutate a file in the subdirectory to ensure the next snapshot has work to do.
247 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "fp.kicad_mod" ),
248 wxS( "(footprint test (modified))\n" ) );
249
250 // CommitFullProjectSnapshot must commit using the project root, not derive a wrong
251 // root from the first file in the recursive collection.
252 BOOST_CHECK( history.CommitFullProjectSnapshot( path, wxS( "PCB Save" ) ) );
253
254 wxString headAfter = history.GetHeadHash( path );
255 BOOST_REQUIRE( !headAfter.IsEmpty() );
256 BOOST_CHECK( headBefore != headAfter );
257}
258
259
260// Regression test for https://gitlab.com/kicad/code/kicad/-/issues/24016
261BOOST_AUTO_TEST_CASE( RestoreCommitPreservesZipBackupsDirectory )
262{
263 LIBGIT2_SCOPE libgit;
264
265 // LOCAL_HISTORY early-exits when backups are disabled.
266 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
267 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
268 backupEnabled = true;
269
270 KI_TEST::SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_issue24016" ) );
271 const wxString& projectPath = tempProject.PathStr();
272
273 wxString boardPath =
274 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016.kicad_pcb" );
275 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
276
277 // LOCAL_HISTORY refuses to operate on directories without a project file, so seed
278 // the fixture with a minimal one to mirror a real KiCad project layout.
279 wxString projectFile =
280 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016.kicad_pro" );
281 writeTextFile( projectFile, wxS( "{}\n" ) );
282
283 LOCAL_HISTORY history;
284 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
285
286 wxString headHash = history.GetHeadHash( projectPath );
287 BOOST_REQUIRE( !headHash.IsEmpty() );
288
289 // Mirror SETTINGS_MANAGER::BackupProject output: a sibling "<name>-backups" directory
290 // containing one or more .zip archives.
291 wxString backupsDir =
292 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016-backups" );
293 BOOST_REQUIRE( wxFileName::Mkdir( backupsDir, 0777, wxPATH_MKDIR_FULL ) );
294
295 wxString zipPath = backupsDir + wxFileName::GetPathSeparator()
296 + wxS( "issue24016-2026-04-22_120000.zip" );
297 writeTextFile( zipPath, wxS( "pretend-zip-contents" ) );
298
299 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
300
301 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
302
303 BOOST_CHECK_MESSAGE( wxDirExists( backupsDir ),
304 "zip backups directory must survive RestoreCommit" );
305 BOOST_CHECK_MESSAGE( wxFileExists( zipPath ),
306 ".zip archive inside the backups directory must survive RestoreCommit" );
307}
308
309
323BOOST_AUTO_TEST_CASE( RestoreCommitPreservesNestedProject )
324{
325 LIBGIT2_SCOPE libgit;
326
327 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
328 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
329 backupEnabled = true;
330
331 KI_TEST::SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_nested_project" ) );
332 const wxString& projectPath = tempProject.PathStr();
333
334 // Parent project (projectA): minimal .kicad_pro plus a board file.
335 wxString parentPro = projectPath + wxFileName::GetPathSeparator() + wxS( "projectA.kicad_pro" );
336 wxString parentPcb = projectPath + wxFileName::GetPathSeparator() + wxS( "projectA.kicad_pcb" );
337 writeTextFile( parentPro, wxS( "{}\n" ) );
338 writeTextFile( parentPcb, wxS( "(kicad_pcb (version 20240108))\n" ) );
339
340 LOCAL_HISTORY history;
341 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
342
343 wxString headHash = history.GetHeadHash( projectPath );
344 BOOST_REQUIRE( !headHash.IsEmpty() );
345
346 // Now drop a nested project under projectA/. The user's scenario was that this nested
347 // project was added AFTER the parent's snapshot was committed - so its files are not in
348 // the restored commit, and a naive restore would propose them for deletion.
349 wxString nestedDir = projectPath + wxFileName::GetPathSeparator() + wxS( "projectB" );
350 BOOST_REQUIRE( wxFileName::Mkdir( nestedDir, 0777, wxPATH_MKDIR_FULL ) );
351
352 wxString nestedPro = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_pro" );
353 wxString nestedPcb = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_pcb" );
354 wxString nestedSch = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_sch" );
355 writeTextFile( nestedPro, wxS( "{ \"nested\": true }\n" ) );
356 writeTextFile( nestedPcb, wxS( "(kicad_pcb (version 20240108) (nested yes))\n" ) );
357 writeTextFile( nestedSch, wxS( "(kicad_sch (version 20240108))\n" ) );
358
359 // Modify the parent board so the restore actually has work to do.
360 writeTextFile( parentPcb, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
361
362 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
363
364 // The nested project's directory and every one of its files MUST survive the restore.
365 BOOST_CHECK_MESSAGE( wxDirExists( nestedDir ),
366 "nested project directory must survive RestoreCommit" );
367 BOOST_CHECK_MESSAGE( wxFileExists( nestedPro ),
368 "nested .kicad_pro must survive RestoreCommit" );
369 BOOST_CHECK_MESSAGE( wxFileExists( nestedPcb ),
370 "nested .kicad_pcb must survive RestoreCommit" );
371 BOOST_CHECK_MESSAGE( wxFileExists( nestedSch ),
372 "nested .kicad_sch must survive RestoreCommit" );
373
374 // Parent project files were correctly restored.
375 BOOST_CHECK( wxFileExists( parentPro ) );
376 BOOST_CHECK( wxFileExists( parentPcb ) );
377}
378
379
385BOOST_AUTO_TEST_CASE( RestoreCommitRetainsTimestampedBackup )
386{
387 LIBGIT2_SCOPE libgit;
388
389 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
390 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
391 backupEnabled = true;
392
393 KI_TEST::SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_retained_backup" ) );
394 const wxString& projectPath = tempProject.PathStr();
395
396 wxString boardPath = projectPath + wxFileName::GetPathSeparator() + wxS( "rb.kicad_pcb" );
397 wxString projectFile = projectPath + wxFileName::GetPathSeparator() + wxS( "rb.kicad_pro" );
398 writeTextFile( projectFile, wxS( "{}\n" ) );
399 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
400
401 LOCAL_HISTORY history;
402 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
403
404 wxString headHash = history.GetHeadHash( projectPath );
405 BOOST_REQUIRE( !headHash.IsEmpty() );
406
407 // Mutate the board so restore has work to do (and produces a backup).
408 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
409
410 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
411
412 // Backups land at a SIBLING path (aProjectPath + "_restore_backup_<ts>"), so look in
413 // the parent directory of the project. Same convention as the legacy "_restore_backup".
414 wxString parentDir = wxFileName( projectPath ).GetPath();
415 wxString leafPrefix = wxFileName( projectPath ).GetFullName() + wxS( "_restore_backup_" );
416
417 wxDir dir( parentDir );
418 BOOST_REQUIRE( dir.IsOpened() );
419
420 wxString retainedBackup;
421 bool foundLegacyBackup = false;
422
423 wxString name;
424 for( bool cont = dir.GetFirst( &name, wxEmptyString, wxDIR_DIRS ); cont;
425 cont = dir.GetNext( &name ) )
426 {
427 if( name.StartsWith( leafPrefix ) )
428 {
429 retainedBackup = parentDir + wxFileName::GetPathSeparator() + name;
430
431 // No colons - Windows path-safe.
432 BOOST_CHECK_MESSAGE( name.Find( ':' ) == wxNOT_FOUND,
433 "retained backup directory name must not contain ':' "
434 "(Windows-illegal in path components)" );
435 }
436 else if( name == wxFileName( projectPath ).GetFullName() + wxS( "_restore_backup" ) )
437 {
438 foundLegacyBackup = true;
439 }
440 }
441
442 BOOST_CHECK_MESSAGE(
443 !retainedBackup.IsEmpty(),
444 "RestoreCommit must retain a timestamped _restore_backup_<ts>/ sibling directory" );
445 BOOST_CHECK_MESSAGE(
446 !foundLegacyBackup,
447 "RestoreCommit must not leave the legacy non-timestamped _restore_backup/ behind" );
448
449 // Clean up the retained backup so the test does not leak files into /tmp.
450 if( !retainedBackup.IsEmpty() && wxDirExists( retainedBackup ) )
451 wxFileName::Rmdir( retainedBackup, wxPATH_RMDIR_RECURSIVE );
452}
453
454
458BOOST_AUTO_TEST_CASE( CommitFullProjectSnapshotExcludesNonKiCadFiles )
459{
460 LIBGIT2_SCOPE libgit;
461
462 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
463 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
464 backupEnabled = true;
465
466 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_privacy" ) );
467 const wxString& path = project.PathStr();
468
469 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
470 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
471 wxS( "(kicad_pcb (version 20240108))\n" ) );
472 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_sch" ),
473 wxS( "(kicad_sch (version 20240108))\n" ) );
474
475 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "passwords.txt" ), wxS( "secret\n" ) );
476 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "datasheet.pdf" ), wxS( "fake pdf bytes\n" ) );
477 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "notes.md" ), wxS( "personal notes\n" ) );
478
479 wxString subDir = path + wxFileName::GetPathSeparator() + wxS( "docs" );
480 BOOST_REQUIRE( wxFileName::Mkdir( subDir, 0777, wxPATH_MKDIR_FULL ) );
481 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "manual.txt" ), wxS( "irrelevant\n" ) );
482
483 LOCAL_HISTORY history;
484 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Close" ) ) );
485
486 wxString hist = path + wxFileName::GetPathSeparator() + wxS( ".history" );
487 git_repository* repo = nullptr;
488 BOOST_REQUIRE_EQUAL( git_repository_open( &repo, hist.mb_str().data() ), 0 );
489
490 git_oid head_oid;
491 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &head_oid, repo, "HEAD" ), 0 );
492
493 git_commit* head = nullptr;
494 BOOST_REQUIRE_EQUAL( git_commit_lookup( &head, repo, &head_oid ), 0 );
495
496 git_tree* tree = nullptr;
497 BOOST_REQUIRE_EQUAL( git_commit_tree( &tree, head ), 0 );
498
499 std::vector<std::string> committedPaths;
500 git_tree_walk(
501 tree, GIT_TREEWALK_PRE,
502 []( const char* root, const git_tree_entry* entry, void* payload ) -> int
503 {
504 auto* paths = static_cast<std::vector<std::string>*>( payload );
505
506 if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
507 paths->push_back( std::string( root ) + git_tree_entry_name( entry ) );
508
509 return 0;
510 },
511 &committedPaths );
512
513 git_tree_free( tree );
514 git_commit_free( head );
515 git_repository_free( repo );
516
517 auto contains = [&]( const std::string& s )
518 {
519 return std::find( committedPaths.begin(), committedPaths.end(), s ) != committedPaths.end();
520 };
521
522 BOOST_CHECK_MESSAGE( contains( "p.kicad_pro" ), "kicad_pro must be committed" );
523 BOOST_CHECK_MESSAGE( contains( "p.kicad_pcb" ), "kicad_pcb must be committed" );
524 BOOST_CHECK_MESSAGE( contains( "p.kicad_sch" ), "kicad_sch must be committed" );
525
526 BOOST_CHECK_MESSAGE( !contains( "passwords.txt" ), "passwords.txt must NOT appear in history" );
527 BOOST_CHECK_MESSAGE( !contains( "datasheet.pdf" ), "datasheet.pdf must NOT appear in history" );
528 BOOST_CHECK_MESSAGE( !contains( "notes.md" ), "notes.md must NOT appear in history" );
529 BOOST_CHECK_MESSAGE( !contains( "docs/manual.txt" ), "subdirectory user content must NOT appear in history" );
530}
531
532
537BOOST_AUTO_TEST_CASE( FirstAutosaveSkipsCommitWhenStagedMatchesDisk )
538{
539 LIBGIT2_SCOPE libgit;
540
541 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
542 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
543 backupEnabled = true;
544
545 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_first_idle_autosave" ) );
546 const wxString& path = project.PathStr();
547
548 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
549 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
550 wxS( "(kicad_pcb (version 20240108))\n" ) );
551
552 LOCAL_HISTORY history;
553
554 // Mutating this between calls simulates the user editing the in-memory document.
555 std::string inMemoryContent = "(kicad_pcb (version 20240108))\n";
556
557 auto saver = [&inMemoryContent]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
558 {
559 HISTORY_FILE_DATA entry;
560 entry.relativePath = wxS( "p.kicad_pcb" );
561 entry.content = inMemoryContent;
562 aFileData.push_back( std::move( entry ) );
563 };
564
565 history.RegisterSaver( &history, saver );
566
567 // Saver output matches disk, must skip (autosave path: empty tagFileType).
568 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
569 history.WaitForPendingSave();
570
571 wxString histDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
572 BOOST_CHECK( wxDirExists( histDir ) );
573
574 wxString head = history.GetHeadHash( path );
575 BOOST_CHECK_MESSAGE( head.IsEmpty(), "no untagged HEAD should exist after an idle first save" );
576
577 // Real edit, must commit.
578 inMemoryContent = "(kicad_pcb (version 20240108) (edited yes))\n";
579
580 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
581 history.WaitForPendingSave();
582
583 head = history.GetHeadHash( path );
584 BOOST_CHECK_MESSAGE( !head.IsEmpty(), "in-memory edits diverging from disk must produce a commit" );
585
586 history.UnregisterSaver( &history );
587}
588
589
596BOOST_AUTO_TEST_CASE( SaverSkippedAfterOwningDocumentDestroyed )
597{
598 LIBGIT2_SCOPE libgit;
599
600 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
601 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
602 backupEnabled = true;
603
604 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_saver_lifetime" ) );
605 const wxString& path = project.PathStr();
606 const wxString sep = wxFileName::GetPathSeparator();
607
608 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
609 writeTextFile( path + sep + wxS( "p.kicad_pcb" ), wxS( "(kicad_pcb (version 20240108))\n" ) );
610
611 LOCAL_HISTORY history;
612
613 // The saver only touches this heap counter, so it stays safe to invoke after the board is gone;
614 // gating it on the board's token is the behaviour under test.
615 auto runCount = std::make_shared<int>( 0 );
616 std::unique_ptr<BOARD> board = std::make_unique<BOARD>();
617
618 history.RegisterSaver( board.get(),
619 [runCount]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
620 {
621 ++( *runCount );
622
623 HISTORY_FILE_DATA entry;
624 entry.relativePath = wxS( "p.kicad_pcb" );
625 entry.content = "(kicad_pcb (version 20240108) (edited yes))\n";
626 aFileData.push_back( std::move( entry ) );
627 },
628 board->GetHistoryLifetimeToken() );
629
630 // Positive control: while the board is alive the saver runs.
631 history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString );
632 history.WaitForPendingSave();
633 BOOST_CHECK_EQUAL( *runCount, 1 );
634
635 // Destroy the document; its expired token must make both runners skip and drop the saver.
636 board.reset();
637
638 history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString );
639 history.WaitForPendingSave();
640 BOOST_CHECK_MESSAGE( *runCount == 1, "commit runner invoked a saver whose board was destroyed" );
641
643 BOOST_CHECK_MESSAGE( *runCount == 1, "autosave-file runner invoked a saver whose board was destroyed" );
644}
645
646
652BOOST_AUTO_TEST_CASE( FirstManualSaveAlwaysCommitsOnFreshProject )
653{
654 LIBGIT2_SCOPE libgit;
655
656 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
657 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
658 backupEnabled = true;
659
660 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_first_manual_save" ) );
661 const wxString& path = project.PathStr();
662
663 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
664 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
665 wxS( "(kicad_pcb (version 20240108))\n" ) );
666
667 LOCAL_HISTORY history;
668
669 std::string inMemoryContent = "(kicad_pcb (version 20240108))\n";
670
671 auto saver = [&inMemoryContent]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
672 {
673 HISTORY_FILE_DATA entry;
674 entry.relativePath = wxS( "p.kicad_pcb" );
675 entry.content = inMemoryContent;
676 aFileData.push_back( std::move( entry ) );
677 };
678
679 history.RegisterSaver( &history, saver );
680
681 // Manual save (non-empty tagFileType) on fresh project: commit even when staged matches disk.
682 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Manual Save" ), wxS( "pcb" ) ) );
683
684 wxString head = history.GetHeadHash( path );
685 BOOST_CHECK_MESSAGE( !head.IsEmpty(), "manual save on a fresh project must commit even when staged matches disk" );
686
687 history.UnregisterSaver( &history );
688}
689
690
691// With backups enabled but the format set to Zip, autosave uses legacy recovery files, so the
692// incremental git-commit path must be a no-op rather than extending a history the user switched
693// off (issue 24773).
694BOOST_AUTO_TEST_CASE( ZipFormatSkipsIncrementalAutosave )
695{
696 LIBGIT2_SCOPE libgit;
697
698 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
699 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
700 backupEnabled = true;
701
703 SCOPED_BACKUP_FORMAT_OVERRIDE restoreFormat( format );
704 format = BACKUP_FORMAT::ZIP;
705
706 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_zip_skips_incremental" ) );
707 const wxString& path = project.PathStr();
708
709 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
710 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
711 wxS( "(kicad_pcb (version 20240108))\n" ) );
712
713 LOCAL_HISTORY history;
714
715 auto saver = []( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
716 {
717 HISTORY_FILE_DATA entry;
718 entry.relativePath = wxS( "p.kicad_pcb" );
719 entry.content = "(kicad_pcb (version 20240108) (edited yes))\n";
720 aFileData.push_back( std::move( entry ) );
721 };
722
723 history.RegisterSaver( &history, saver );
724
725 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
726 history.WaitForPendingSave();
727
728 BOOST_CHECK_MESSAGE( history.GetHeadHash( path ).IsEmpty(),
729 "zip backup format must not create incremental autosave commits" );
730
731 history.UnregisterSaver( &history );
732}
733
734
735// The Zip backup format must reliably write legacy recovery files on autosave so a crash does
736// not lose work between manual saves (issue 24773).
737BOOST_AUTO_TEST_CASE( ZipFormatWritesRecoveryFiles )
738{
739 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
740 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
741 backupEnabled = true;
742
744 SCOPED_BACKUP_FORMAT_OVERRIDE restoreFormat( format );
745 format = BACKUP_FORMAT::ZIP;
746
748 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
750
751 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_zip_recovery_files" ) );
752 const wxString& path = project.PathStr();
753 const wxString sep = wxFileName::GetPathSeparator();
754
755 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
756
758 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
759
760 LOCAL_HISTORY history;
761
762 auto saver = []( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
763 {
764 HISTORY_FILE_DATA entry;
765 entry.relativePath = wxS( "p.kicad_pcb" );
766 entry.content = "(kicad_pcb (version 20240108) (edited yes))\n";
767 aFileData.push_back( std::move( entry ) );
768 };
769
770 history.RegisterSaver( &history, saver );
771
773
774 wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
775 BOOST_CHECK_MESSAGE( wxFileExists( autosavePath ),
776 "zip backup format must write autosave recovery files" );
777
778 history.UnregisterSaver( &history );
779}
780
781
782// A content-identical autosave with a newer mtime (cloud-sync touch) must not be flagged
783// stale, or recovery prompts fire on every open with nothing to restore (issue 24126).
784BOOST_AUTO_TEST_CASE( CloudSyncTouchedAutosaveFalselyFlaggedStale )
785{
786 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
787 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
788 backupEnabled = true;
789
791 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
793
794 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_cloudsync_autosave" ) );
795 const wxString& path = project.PathStr();
796 const wxString sep = wxFileName::GetPathSeparator();
797
798 // FindStaleAutosaveFiles resolves the autosave root through the active project, so it must
799 // be loaded; the .kicad_pro must exist on disk first for LoadProject to succeed.
800 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
801
803 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
804
805 const wxString sourcePath = path + sep + wxS( "p.kicad_pcb" );
806 const wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
807 const wxString boardContent = wxS( "(kicad_pcb (version 20240108))\n" );
808
809 // Byte-identical source and "_autosave-" companion, mirroring a clean save.
810 writeTextFile( sourcePath, boardContent );
811 writeTextFile( autosavePath, boardContent );
812
813 LOCAL_HISTORY history;
814
815 // Cloud-sync touch gives the identical autosave a strictly newer mtime.
816 wxDateTime srcMtime = wxFileName( sourcePath ).GetModificationTime();
817 wxDateTime newerMtime = srcMtime + wxTimeSpan::Seconds( 60 );
818 BOOST_REQUIRE( wxFileName( autosavePath ).SetTimes( &newerMtime, &newerMtime, nullptr ) );
819
820 std::vector<wxString> exts{ wxS( "kicad_pcb" ) };
821 auto stale = history.FindStaleAutosaveFiles( path, exts );
822
823 BOOST_CHECK_MESSAGE( stale.empty(),
824 "Content-identical autosave with newer mtime was flagged stale, "
825 "triggering a spurious recovery prompt (issue 24126)" );
826}
827
828
829// Counterpart to the above: an autosave whose content genuinely differs (a real unsaved edit)
830// must still be flagged stale so recovery continues to fire.
831BOOST_AUTO_TEST_CASE( DivergentAutosaveStillFlaggedStale )
832{
833 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
834 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
835 backupEnabled = true;
836
838 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
840
841 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_divergent_autosave" ) );
842 const wxString& path = project.PathStr();
843 const wxString sep = wxFileName::GetPathSeparator();
844
845 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
846
848 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
849
850 const wxString sourcePath = path + sep + wxS( "p.kicad_pcb" );
851 const wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
852
853 // Autosave content diverges from the source, a genuine unsaved edit to recover.
854 writeTextFile( sourcePath, wxS( "(kicad_pcb (version 20240108))\n" ) );
855 writeTextFile( autosavePath, wxS( "(kicad_pcb (version 20240108) (edited yes))\n" ) );
856
857 LOCAL_HISTORY history;
858
859 wxDateTime srcMtime = wxFileName( sourcePath ).GetModificationTime();
860 wxDateTime newerMtime = srcMtime + wxTimeSpan::Seconds( 60 );
861 BOOST_REQUIRE( wxFileName( autosavePath ).SetTimes( &newerMtime, &newerMtime, nullptr ) );
862
863 std::vector<wxString> exts{ wxS( "kicad_pcb" ) };
864 auto stale = history.FindStaleAutosaveFiles( path, exts );
865
866 BOOST_CHECK_MESSAGE( stale.size() == 1,
867 "Genuinely divergent autosave must still be flagged stale for recovery" );
868}
869
870
871BOOST_AUTO_TEST_CASE( EnforceSizeLimitKeepsUnstagedFilesInNextSnapshot )
872{
873 LIBGIT2_SCOPE libgit;
874
875 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
876 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
877 backupEnabled = true;
878
879 KI_TEST::SCOPED_TEMP_DIR project( wxS( "kicad_qa_trim_partial_save" ) );
880 const wxString& path = project.PathStr();
881 const wxString sep = wxFileName::GetPathSeparator();
882
883 writeTextFile( path + sep + wxS( "trim.kicad_pro" ), wxS( "{}\n" ) );
884 writeTextFile( path + sep + wxS( "trim.kicad_pcb" ), wxS( "(kicad_pcb (version 20240108))\n" ) );
885 writeTextFile( path + sep + wxS( "trim.kicad_sch" ), wxS( "(kicad_sch (version 20240108))\n" ) );
886
887 LOCAL_HISTORY history;
888 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Initial" ) ) );
889
890 writeTextFile( path + sep + wxS( "trim.kicad_pcb" ), wxS( "(kicad_pcb (version 20240108) (net 1))\n" ) );
891 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Second" ) ) );
892
893 BOOST_REQUIRE( history.EnforceSizeLimit( path, 1 ) );
894
895 writeTextFile( path + sep + wxS( "trim.kicad_sch" ), wxS( "(kicad_sch (version 20240108) (mod))\n" ) );
896 BOOST_REQUIRE( history.CommitSnapshot( { path + sep + wxS( "trim.kicad_sch" ) }, wxS( "Sch Save" ) ) );
897
898 wxString historyDir = path + sep + wxS( ".history" );
899 git_repository* repo = nullptr;
900 BOOST_REQUIRE( git_repository_open( &repo, historyDir.mb_str().data() ) == 0 );
901
902 git_oid headOid;
903 git_commit* head = nullptr;
904 git_tree* tree = nullptr;
905 BOOST_REQUIRE( git_reference_name_to_id( &headOid, repo, "HEAD" ) == 0 );
906 BOOST_REQUIRE( git_commit_lookup( &head, repo, &headOid ) == 0 );
907 BOOST_REQUIRE( git_commit_tree( &tree, head ) == 0 );
908
909 BOOST_CHECK_MESSAGE( git_tree_entry_byname( tree, "trim.kicad_sch" ),
910 "schematic missing from snapshot after trim" );
911 BOOST_CHECK_MESSAGE( git_tree_entry_byname( tree, "trim.kicad_pcb" ), "board dropped from snapshot after trim" );
912 BOOST_CHECK_MESSAGE( git_tree_entry_byname( tree, "trim.kicad_pro" ),
913 "project file dropped from snapshot after trim" );
914
915 git_tree_free( tree );
916 git_commit_free( head );
917 git_repository_free( repo );
918}
919
920
const char * name
Information pertinent to a Pcbnew printed circuit board.
Definition board.h:409
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:4510
void SetProject(PROJECT *aProject, bool aReferenceOnly=false)
Link a board to a given project.
Definition board.cpp:374
const wxString & GetFileName() const
Definition board.h:452
void ClearProject()
Definition board.cpp:415
PROJECT * GetProject() const
Definition board.h:767
AUTO_BACKUP m_Backup
wxString PathStr() const
Get the path to the temporary directory as a wxString.
Definition file_utils.h:62
Simple local history manager built on libgit2.
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.
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 CommitSnapshot(const std::vector< wxString > &aFiles, const wxString &aTitle)
Commit the given files to the local history repository.
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:546
virtual SETTINGS_MANAGER & GetSettingsManager() const
Definition pgm_base.h:123
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")