KiCad PCB EDA Suite
Loading...
Searching...
No Matches
test_kigit_common.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
31#include <qa_utils/file_utils.h>
33
34#include <git2.h>
35#include <git2/sys/repository.h>
36
37#include <wx/app.h>
38#include <wx/file.h>
39#include <wx/filename.h>
40#include <wx/log.h>
41#include <wx/string.h>
42#include <wx/stdpaths.h>
43
44#include <chrono>
45#include <algorithm>
46#include <fstream>
47#include <future>
48#include <iterator>
49#include <memory>
50#include <set>
51#include <stdexcept>
52#include <thread>
53
54#ifndef __WINDOWS__
55#include <unistd.h>
56#endif
57
58
59namespace
60{
61
62// Throwaway keys, generated for these tests and used nowhere else.
63
64const char* c_opensshEncrypted = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
65 "b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAxS2G8mY\n"
66 "+85LKkVcFtJi1GAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIKB0oSTjiyvxC+qh\n"
67 "cJ2zEnPDzZWArHcl90v2Se1SkmoGAAAAkKKrIYipMtR+JUEsuoYSmnfD0b/ng57Fq2uj25\n"
68 "oHDRqrDyPKS0xqQJa9+IQvPeM+E3G1fAGvsOYTQXsLJzXuQDiLX3vceEuN9AmJkA9Naea4\n"
69 "qAPy8CTlBx/ktdd4YgnYXBETi6Ab1f/h7LAVlLJTOc9Ta9HilKzO8Vl5p3EUS1PWkfGGqe\n"
70 "QLo1/ryfJjC3ZZbA==\n"
71 "-----END OPENSSH PRIVATE KEY-----\n";
72
73
74const char* c_opensshPlain = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
75 "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n"
76 "QyNTUxOQAAACD4Xd9qfxjO+vpBXO94Otoa0kg/p5H8kMYhN6eFe7v8MgAAAIjYZhWO2GYV\n"
77 "jgAAAAtzc2gtZWQyNTUxOQAAACD4Xd9qfxjO+vpBXO94Otoa0kg/p5H8kMYhN6eFe7v8Mg\n"
78 "AAAEBNlyz61Kiufy1a3WO6i2I7vB+7W2iQc8R81H0qrBheSfhd32p/GM76+kFc73g62hrS\n"
79 "SD+nkfyQxiE3p4V7u/wyAAAAAXQBAgME\n"
80 "-----END OPENSSH PRIVATE KEY-----\n";
81
82
83const char* c_opensshTruncated = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
84 "b3BlbnNzaC1r\n"
85 "-----END OPENSSH PRIVATE KEY-----\n";
86
87
88const char* c_pemEncrypted = "-----BEGIN EC PRIVATE KEY-----\n"
89 "Proc-Type: 4,ENCRYPTED\n"
90 "DEK-Info: AES-128-CBC,10297828DB5031A5C22276D18E077B61\n"
91 "\n"
92 "rHfBqMZtByvcsRb7zgNtkSjhFgnRY3jpyyagjM8jxNoEDRaSSHNSugON80psxytE\n"
93 "HBTGShcJbDXOC7EER7QZOTjM/d+3DB+FEL4EwnJxgnVfWN/5p9hP3CoJLgeItkQV\n"
94 "POqt3mQ1V8AnJibunyn13YrczHM39IFruBB9JvNKKG+i/7yf4Jy4jCB9nicH7TC+\n"
95 "hQCu/mOMde02ziwULJiWIU3EALSYo6JhDJxTRIqUr7TDGXEAPcxpQEvMDz+HAgzY\n"
96 "9zTU6LC/CSbHQNjHYSvgDY4zRDFWxeHgNeQVfqrlOPGGE8NC5HCeo5i1r+rSVPn+\n"
97 "kzqXcGpwN682lM/oJV4ix3her0jf59RnAPKYwyWXfhUwYFtzFwroOoquFi6+HHVT\n"
98 "LlzJacbVVj/GTBOdHBSsu8cfiVLymHhEEzQCyIYY05DeHFe8GiSwDDdvMCPppX38\n"
99 "/R3PnNaEYwOEUK4o50nsWEfPHQw8kZaUkiUcX7uIyV4=\n"
100 "-----END EC PRIVATE KEY-----\n";
101
102
103const char* c_pemPlain = "-----BEGIN EC PRIVATE KEY-----\n"
104 "MIIBaAIBAQQgGuajOowPzHaTJKkj1W+RQS0p87K8bEkZbgbw5pmVArmggfowgfcC\n"
105 "AQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAAAAAAAAAAAAAA////////////////\n"
106 "MFsEIP////8AAAABAAAAAAAAAAAAAAAA///////////////8BCBaxjXYqjqT57Pr\n"
107 "vVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSdNgiG5wSTamZ44ROdJreBn36QBEEE\n"
108 "axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li/hp/m47n60p8D54W\n"
109 "K84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8\n"
110 "YyVRAgEBoUQDQgAELhPMgsc2xVYFBrKzqXq8CBRS8t9BBCnznM96FcZFv1MvdbIK\n"
111 "yn1nix57Bwq0VoyooErjaZ3SpXl47JlJpIsm4A==\n"
112 "-----END EC PRIVATE KEY-----\n";
113
114
115const char* c_pkcs8Encrypted = "-----BEGIN ENCRYPTED PRIVATE KEY-----\n"
116 "MIIBzzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQImaRUWsA/SFECAggA\n"
117 "MB0GCWCGSAFlAwQBAgQQiywEwYp7En3NzOemv0ek9QSCAYAIh56t3GB2r1S/B7Cy\n"
118 "UFS4qn1DvGDuhubEl6cw3n8Y1PnWj6ryq6O9Pw/DKdnKonzVN+PVaofXg26ZPLdR\n"
119 "q+J7fmcMPIEC0DqV/6T+oGqWp2iaiTZ11BBOoyBkd8ev59c/TxngOWlbJvvmGhEF\n"
120 "mOiCwXKoKjzU8kSVbmL+vFe1thZjrCaihL79JsZE6Ws5bd7yAn6grPBPI8sBexNi\n"
121 "fERG81T9Nu2mxCEd7zkNAoK0cZBnUwFvxDama18wMvXRYzEHETCUCWevoNFdcwo/\n"
122 "IkGgLciMxtSBBOXyWiVvAzTloSGHa5wafptTsgyTc1HKZBTR0ZTNvBX37aziggWE\n"
123 "u4fMHcHlAJwP4UXgCACtgcfktVhjohgNQdBm5RB95qEUdNAQDQSe4Ro27HfT0YyR\n"
124 "AHZYNXziRjZHsO6wqGarcxdZkH1Cy8Ca9FJr3uRWWr+8CWIjE9nDEkFbXUyQGOPq\n"
125 "sTAC7FpnQHVczHcaa45jXYaPmU/iNFnPkO2DzxWADe/1xIg=\n"
126 "-----END ENCRYPTED PRIVATE KEY-----\n";
127
128
129// Initialize a git repository at the given path with one initial commit on "main"
130git_repository* makeRepoWithCommit( const wxString& aRepoPath, const wxString& aFileName,
131 const std::string& aFileContents )
132{
134 KI_TEST::WriteGitFile( aRepoPath, aFileName, aFileContents );
135 KI_TEST::CommitGitFile( repo.get(), aFileName, "initial" );
136 return repo.release();
137}
138
139
140// Stage aFileName from the working tree and commit it on top of HEAD.
141void commitWorkingFile( git_repository* aRepo, const wxString& aFileName, const char* aMessage )
142{
143 KI_TEST::CommitGitFile( aRepo, aFileName, aMessage );
144}
145
146
147std::string readWorkingFile( const wxFileName& aFile )
148{
149 std::ifstream in( aFile.GetFullPath().utf8_string() );
150 return std::string( ( std::istreambuf_iterator<char>( in ) ), std::istreambuf_iterator<char>() );
151}
152
153
154void writeFile( const wxString& aRepoPath, const wxString& aFileName, const char* aContent )
155{
156 KI_TEST::WriteGitFile( aRepoPath, aFileName, aContent );
157}
158
159
160// Query completions use the application's event queue, just as they do for a dialog.
161bool waitForQuery( const std::function<bool()>& aFinished )
162{
163 const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds( 5 );
164
165 while( !aFinished() && std::chrono::steady_clock::now() < deadline )
166 {
167 wxTheApp->ProcessPendingEvents();
168 std::this_thread::sleep_for( std::chrono::milliseconds( 1 ) );
169 }
170
171 return aFinished();
172}
173
174
175// Like wxWeakRef, every copy and destruction of this callback capture must stay on the UI
176// thread. A shared pointee alone would miss copies made and destroyed while posting an event.
177struct UI_CALLBACK_CAPTURE
178{
179 explicit UI_CALLBACK_CAPTURE( std::shared_ptr<std::atomic<bool>> aWrongThread ) :
180 wrongThread( std::move( aWrongThread ) ),
181 uiThread( std::this_thread::get_id() )
182 {
183 }
184
185 UI_CALLBACK_CAPTURE( const UI_CALLBACK_CAPTURE& aOther ) :
186 wrongThread( aOther.wrongThread ),
187 uiThread( aOther.uiThread )
188 {
189 if( std::this_thread::get_id() != uiThread )
190 wrongThread->store( true );
191 }
192
193 UI_CALLBACK_CAPTURE( UI_CALLBACK_CAPTURE&& ) = default;
194
195 ~UI_CALLBACK_CAPTURE()
196 {
197 if( wrongThread && std::this_thread::get_id() != uiThread )
198 wrongThread->store( true );
199 }
200
201 std::shared_ptr<std::atomic<bool>> wrongThread;
202 std::thread::id uiThread;
203};
204
205} // namespace
206
207
208BOOST_FIXTURE_TEST_SUITE( KiGitCommon, KI_TEST::GIT_TEST_FIXTURE )
209
210
211// git_repository_path() returns a path ending in /.git/, which broke the popup-menu test for
212// whether a project lives at the repository root.
213BOOST_AUTO_TEST_CASE( GitRootDirectoryReturnsWorkdir )
214{
215 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "data\n" );
216 KIGIT::GitRepositoryPtr repoPtr( repo );
217
218 KIGIT_COMMON common( repo );
219 wxString root = common.GetGitRootDirectory();
220
221 BOOST_CHECK( !root.IsEmpty() );
222 BOOST_CHECK_MESSAGE( !root.Contains( wxS( "/.git" ) ) && !root.Contains( wxS( "\\.git" ) ),
223 "GetGitRootDirectory should return the working directory, got: "
224 + root.ToStdString() );
225
226 // Should be the repo path (with trailing separator).
227 wxFileName rootFn;
228 rootFn.AssignDir( root );
229 rootFn.MakeAbsolute();
230 wxFileName tmpFn;
231 tmpFn.AssignDir( TempPath() );
232 tmpFn.MakeAbsolute();
233 BOOST_CHECK_EQUAL( rootFn.GetFullPath().ToStdString(), tmpFn.GetFullPath().ToStdString() );
234}
235
236
237// The remote need not be called origin.
238BOOST_AUTO_TEST_CASE( HasPushAndPullRemoteAcceptsNonOriginName )
239{
240 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "data\n" );
241 KIGIT::GitRepositoryPtr repoPtr( repo );
242
243 // No remotes configured.
244 KIGIT_COMMON common( repo );
245 BOOST_CHECK( !common.HasPushAndPullRemote() );
246
247 // Add a remote with a non-default name.
248 git_remote* remote = nullptr;
249 BOOST_REQUIRE_EQUAL( git_remote_create( &remote, repo, "github",
250 "[email protected]:example/repo.git" ),
251 GIT_OK );
252 KIGIT::GitRemotePtr remotePtr( remote );
253
254 BOOST_CHECK( common.HasPushAndPullRemote() );
255}
256
257
258BOOST_AUTO_TEST_CASE( HasPushAndPullRemoteFindsOrigin )
259{
260 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "data\n" );
261 KIGIT::GitRepositoryPtr repoPtr( repo );
262
263 git_remote* remote = nullptr;
264 BOOST_REQUIRE_EQUAL( git_remote_create( &remote, repo, "origin",
265 "[email protected]:repo.git" ),
266 GIT_OK );
267 KIGIT::GitRemotePtr remotePtr( remote );
268
269 KIGIT_COMMON common( repo );
270 BOOST_CHECK( common.HasPushAndPullRemote() );
271}
272
273
274// Walking unbounded history with no upstream OID dumped the whole root-commit tree into the
275// modified set, falsely flagging every file as ahead of the remote.
276BOOST_AUTO_TEST_CASE( GetDifferentFilesEmptyWithoutUpstream )
277{
278 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "data\n" );
279 KIGIT::GitRepositoryPtr repoPtr( repo );
280
281 KIGIT_COMMON common( repo );
282 auto [local, remote] = common.GetDifferentFiles();
283
284 BOOST_CHECK_MESSAGE( local.empty(),
285 "Expected no AHEAD files when no upstream is configured, got "
286 + std::to_string( local.size() ) );
287 BOOST_CHECK_MESSAGE( remote.empty(),
288 "Expected no BEHIND files when no upstream is configured, got "
289 + std::to_string( remote.size() ) );
290}
291
292
293// With disjoint histories the walk ran past the upstream to the local root commit, and a root
294// commit has no parent, so its whole tree landed in the AHEAD set.
295BOOST_AUTO_TEST_CASE( GetDifferentFilesHandlesUnrelatedHistories )
296{
297 // Local root commit with two files. The bug previously listed both as AHEAD.
298 git_repository* repo = makeRepoWithCommit( TempPath(), "untouched.txt", "stable\n" );
299 KIGIT::GitRepositoryPtr repoPtr( repo );
300
301 // Add a second file to local HEAD so we have more than the bare initial tree.
302 {
303 wxFileName extra( TempPath(), wxS( "second.txt" ) );
304 std::ofstream f( extra.GetFullPath().utf8_string() );
305 f << "more\n";
306 }
307
308 git_index* index = nullptr;
309 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
310 KIGIT::GitIndexPtr indexPtr( index );
311 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "second.txt" ), GIT_OK );
312 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
313
314 git_oid newTreeOid;
315 BOOST_REQUIRE_EQUAL( git_index_write_tree( &newTreeOid, index ), GIT_OK );
316
317 git_tree* newTree = nullptr;
318 BOOST_REQUIRE_EQUAL( git_tree_lookup( &newTree, repo, &newTreeOid ), GIT_OK );
319 KIGIT::GitTreePtr newTreePtr( newTree );
320
321 git_reference* head = nullptr;
322 BOOST_REQUIRE_EQUAL( git_repository_head( &head, repo ), GIT_OK );
323 KIGIT::GitReferencePtr headPtr( head );
324
325 git_commit* parentCommit = nullptr;
326 BOOST_REQUIRE_EQUAL(
327 git_commit_lookup( &parentCommit, repo, git_reference_target( head ) ), GIT_OK );
328 KIGIT::GitCommitPtr parentCommitPtr( parentCommit );
329
330 git_signature* sig = nullptr;
331 BOOST_REQUIRE_EQUAL( git_signature_now( &sig, "QA Test", "[email protected]" ), GIT_OK );
332 KIGIT::GitSignaturePtr sigPtr( sig );
333
334 const git_commit* parents[1] = { parentCommit };
335 git_oid secondCommitOid;
336 BOOST_REQUIRE_EQUAL( git_commit_create( &secondCommitOid, repo, "HEAD", sig, sig, nullptr,
337 "second", newTree, 1, parents ),
338 GIT_OK );
339
340 // Build a disjoint upstream history with its own root commit and a different file.
341 git_treebuilder* tb = nullptr;
342 BOOST_REQUIRE_EQUAL( git_treebuilder_new( &tb, repo, nullptr ), GIT_OK );
343
344 git_oid blobOid;
345 const std::string upstreamData = "upstream\n";
346 BOOST_REQUIRE_EQUAL( git_blob_create_from_buffer( &blobOid, repo, upstreamData.data(),
347 upstreamData.size() ),
348 GIT_OK );
349 BOOST_REQUIRE_EQUAL( git_treebuilder_insert( nullptr, tb, "remote_only.txt", &blobOid,
350 GIT_FILEMODE_BLOB ),
351 GIT_OK );
352
353 git_oid remoteTreeOid;
354 BOOST_REQUIRE_EQUAL( git_treebuilder_write( &remoteTreeOid, tb ), GIT_OK );
355 git_treebuilder_free( tb );
356
357 git_tree* remoteTree = nullptr;
358 BOOST_REQUIRE_EQUAL( git_tree_lookup( &remoteTree, repo, &remoteTreeOid ), GIT_OK );
359 KIGIT::GitTreePtr remoteTreePtr( remoteTree );
360
361 git_oid remoteCommitOid;
362 BOOST_REQUIRE_EQUAL( git_commit_create( &remoteCommitOid, repo, nullptr, sig, sig, nullptr,
363 "upstream root", remoteTree, 0, nullptr ),
364 GIT_OK );
365
366 git_reference* remoteRef = nullptr;
367 BOOST_REQUIRE_EQUAL( git_reference_create( &remoteRef, repo, "refs/remotes/origin/main",
368 &remoteCommitOid, true, nullptr ),
369 GIT_OK );
370 KIGIT::GitReferencePtr remoteRefPtr( remoteRef );
371
372 git_config* cfg = nullptr;
373 BOOST_REQUIRE_EQUAL( git_repository_config( &cfg, repo ), GIT_OK );
374 KIGIT::GitConfigPtr cfgPtr( cfg );
375 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.remote", "origin" ),
376 GIT_OK );
377 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.merge", "refs/heads/main" ),
378 GIT_OK );
379
380 KIGIT_COMMON common( repo );
381 auto [local, remote] = common.GetDifferentFiles();
382
383 // With unrelated histories there is no merge base, so the implementation cannot
384 // attribute changes to either side. Pre-fix, the revwalk reached both root commits
385 // and dumped both trees in full; the new merge-base approach short-circuits and
386 // reports nothing on either side in this ambiguous state.
387 BOOST_CHECK_MESSAGE( local.empty(), "no files should be reported as AHEAD when the local and remote "
388 "histories share no ancestor" );
389 BOOST_CHECK_MESSAGE( remote.empty(), "no files should be reported as BEHIND when the local and remote "
390 "histories share no ancestor" );
391}
392
393
394// Files present unchanged in both trees must not show up as ahead.
395BOOST_AUTO_TEST_CASE( GetDifferentFilesReportsOnlyTouchedFilesWhenAhead )
396{
397 // Initial commit becomes the shared base. Two files: only one will be modified later.
398 git_repository* repo = makeRepoWithCommit( TempPath(), "untouched.txt", "stable\n" );
399 KIGIT::GitRepositoryPtr repoPtr( repo );
400
401 {
402 wxFileName extra( TempPath(), wxS( "touched.txt" ) );
403 std::ofstream f( extra.GetFullPath().utf8_string() );
404 f << "v1\n";
405 }
406
407 git_index* index = nullptr;
408 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
409 KIGIT::GitIndexPtr indexPtr( index );
410 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "touched.txt" ), GIT_OK );
411 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
412
413 git_oid treeOid;
414 BOOST_REQUIRE_EQUAL( git_index_write_tree( &treeOid, index ), GIT_OK );
415
416 git_tree* tree = nullptr;
417 BOOST_REQUIRE_EQUAL( git_tree_lookup( &tree, repo, &treeOid ), GIT_OK );
418 KIGIT::GitTreePtr treePtr( tree );
419
420 git_reference* head = nullptr;
421 BOOST_REQUIRE_EQUAL( git_repository_head( &head, repo ), GIT_OK );
422 KIGIT::GitReferencePtr headPtr( head );
423
424 git_commit* parent = nullptr;
425 BOOST_REQUIRE_EQUAL( git_commit_lookup( &parent, repo, git_reference_target( head ) ),
426 GIT_OK );
427 KIGIT::GitCommitPtr parentPtr( parent );
428
429 git_signature* sig = nullptr;
430 BOOST_REQUIRE_EQUAL( git_signature_now( &sig, "QA Test", "[email protected]" ), GIT_OK );
431 KIGIT::GitSignaturePtr sigPtr( sig );
432
433 const git_commit* parents[1] = { parent };
434 git_oid base_oid;
435 BOOST_REQUIRE_EQUAL( git_commit_create( &base_oid, repo, "HEAD", sig, sig, nullptr,
436 "add touched", tree, 1, parents ),
437 GIT_OK );
438
439 // Mark this commit as the upstream tip and configure a remote so libgit2 accepts the
440 // branch.main.remote = origin pointer below.
441 git_remote* origin = nullptr;
442 BOOST_REQUIRE_EQUAL( git_remote_create( &origin, repo, "origin",
443 "[email protected]:repo.git" ),
444 GIT_OK );
445 KIGIT::GitRemotePtr originPtr( origin );
446
447 git_reference* upstream_ref = nullptr;
448 BOOST_REQUIRE_EQUAL( git_reference_create( &upstream_ref, repo, "refs/remotes/origin/main",
449 &base_oid, true, nullptr ),
450 GIT_OK );
451 KIGIT::GitReferencePtr upstreamRefPtr( upstream_ref );
452
453 git_config* cfg = nullptr;
454 BOOST_REQUIRE_EQUAL( git_repository_config( &cfg, repo ), GIT_OK );
455 KIGIT::GitConfigPtr cfgPtr( cfg );
456 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.remote", "origin" ),
457 GIT_OK );
458 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.merge", "refs/heads/main" ),
459 GIT_OK );
460
461 // Local-only commit modifying just touched.txt.
462 {
463 wxFileName extra( TempPath(), wxS( "touched.txt" ) );
464 std::ofstream f( extra.GetFullPath().utf8_string() );
465 f << "v2\n";
466 }
467
468 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "touched.txt" ), GIT_OK );
469 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
470
471 git_oid newTreeOid;
472 BOOST_REQUIRE_EQUAL( git_index_write_tree( &newTreeOid, index ), GIT_OK );
473
474 git_tree* newTree = nullptr;
475 BOOST_REQUIRE_EQUAL( git_tree_lookup( &newTree, repo, &newTreeOid ), GIT_OK );
476 KIGIT::GitTreePtr newTreePtr( newTree );
477
478 git_commit* baseCommit = nullptr;
479 BOOST_REQUIRE_EQUAL( git_commit_lookup( &baseCommit, repo, &base_oid ), GIT_OK );
480 KIGIT::GitCommitPtr baseCommitPtr( baseCommit );
481
482 const git_commit* aheadParents[1] = { baseCommit };
483 git_oid aheadOid;
484 BOOST_REQUIRE_EQUAL( git_commit_create( &aheadOid, repo, "HEAD", sig, sig, nullptr,
485 "modify touched", newTree, 1, aheadParents ),
486 GIT_OK );
487
488 KIGIT_COMMON common( repo );
489 auto [local, remote] = common.GetDifferentFiles();
490
491 BOOST_CHECK( remote.empty() );
492 BOOST_CHECK_MESSAGE( local.find( wxS( "touched.txt" ) ) != local.end(),
493 "touched.txt should be reported as AHEAD" );
494 BOOST_CHECK_MESSAGE( local.find( wxS( "untouched.txt" ) ) == local.end(),
495 "untouched.txt should NOT be reported as AHEAD - this was the "
496 "regression in issue 21576" );
497}
498
499
500BOOST_AUTO_TEST_CASE( PrivateKeyEncryptionOpenSSHFormat )
501{
502 BOOST_CHECK_MESSAGE( KIGIT::IsPrivateKeyEncrypted( c_opensshEncrypted ),
503 "A passphrase-protected OpenSSH key must be reported as encrypted" );
504
505 BOOST_CHECK_MESSAGE( !KIGIT::IsPrivateKeyEncrypted( c_opensshPlain ),
506 "An OpenSSH key with cipher \"none\" must be reported as unencrypted" );
507
508 BOOST_CHECK_MESSAGE( !KIGIT::IsPrivateKeyEncrypted( c_opensshTruncated ),
509 "An OpenSSH key with an unreadable body must not be reported as encrypted" );
510}
511
512
513BOOST_AUTO_TEST_CASE( PrivateKeyEncryptionClassicPEMFormat )
514{
515 BOOST_CHECK_MESSAGE( KIGIT::IsPrivateKeyEncrypted( c_pemEncrypted ),
516 "A PEM key with Proc-Type: 4,ENCRYPTED must be reported as encrypted" );
517
518 BOOST_CHECK_MESSAGE( !KIGIT::IsPrivateKeyEncrypted( c_pemPlain ),
519 "A PEM key with no Proc-Type must be reported as unencrypted" );
520}
521
522
523BOOST_AUTO_TEST_CASE( PrivateKeyEncryptionPKCS8Format )
524{
525 BOOST_CHECK_MESSAGE( KIGIT::IsPrivateKeyEncrypted( c_pkcs8Encrypted ),
526 "A PKCS#8 key headed ENCRYPTED PRIVATE KEY must be reported as encrypted" );
527}
528
529
530BOOST_AUTO_TEST_CASE( PrivateKeyEncryptionRejectsNonKeys )
531{
532 BOOST_CHECK( !KIGIT::IsPrivateKeyEncrypted( wxEmptyString ) );
533
534 BOOST_CHECK_MESSAGE( !KIGIT::IsPrivateKeyEncrypted( "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKB0 t\n" ),
535 "A public key is not an encrypted private key" );
536}
537
538
539// A file changed only on the remote tip, with the local branch still at the shared base, must
540// land in the BEHIND (remote) set and not in the AHEAD (local) set.
541BOOST_AUTO_TEST_CASE( GetDifferentFilesReportsRemoteOnlyFilesWhenBehind )
542{
543 git_repository* repo = makeRepoWithCommit( TempPath(), "untouched.txt", "stable\n" );
544 KIGIT::GitRepositoryPtr repoPtr( repo );
545
546 git_reference* head = nullptr;
547 BOOST_REQUIRE_EQUAL( git_repository_head( &head, repo ), GIT_OK );
548 KIGIT::GitReferencePtr headPtr( head );
549
550 git_oid baseOid = *git_reference_target( head );
551
552 git_commit* baseCommit = nullptr;
553 BOOST_REQUIRE_EQUAL( git_commit_lookup( &baseCommit, repo, &baseOid ), GIT_OK );
554 KIGIT::GitCommitPtr baseCommitPtr( baseCommit );
555
556 // Build a tree that adds remote_only.txt on top of the base, then commit it without moving
557 // HEAD so the local branch stays behind.
558 {
559 wxFileName extra( TempPath(), wxS( "remote_only.txt" ) );
560 std::ofstream f( extra.GetFullPath().utf8_string() );
561 f << "from remote\n";
562 }
563
564 git_index* index = nullptr;
565 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
566 KIGIT::GitIndexPtr indexPtr( index );
567 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "remote_only.txt" ), GIT_OK );
568 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
569
570 git_oid treeOid;
571 BOOST_REQUIRE_EQUAL( git_index_write_tree( &treeOid, index ), GIT_OK );
572
573 git_tree* tree = nullptr;
574 BOOST_REQUIRE_EQUAL( git_tree_lookup( &tree, repo, &treeOid ), GIT_OK );
575 KIGIT::GitTreePtr treePtr( tree );
576
577 git_signature* sig = nullptr;
578 BOOST_REQUIRE_EQUAL( git_signature_now( &sig, "QA Test", "[email protected]" ), GIT_OK );
579 KIGIT::GitSignaturePtr sigPtr( sig );
580
581 const git_commit* parents[1] = { baseCommit };
582 git_oid remoteOid;
583 BOOST_REQUIRE_EQUAL(
584 git_commit_create( &remoteOid, repo, nullptr, sig, sig, nullptr, "add remote_only", tree, 1, parents ),
585 GIT_OK );
586
587 git_remote* origin = nullptr;
588 BOOST_REQUIRE_EQUAL( git_remote_create( &origin, repo, "origin", "[email protected]:repo.git" ), GIT_OK );
589 KIGIT::GitRemotePtr originPtr( origin );
590
591 git_reference* upstream_ref = nullptr;
592 BOOST_REQUIRE_EQUAL(
593 git_reference_create( &upstream_ref, repo, "refs/remotes/origin/main", &remoteOid, true, nullptr ),
594 GIT_OK );
595 KIGIT::GitReferencePtr upstreamRefPtr( upstream_ref );
596
597 git_config* cfg = nullptr;
598 BOOST_REQUIRE_EQUAL( git_repository_config( &cfg, repo ), GIT_OK );
599 KIGIT::GitConfigPtr cfgPtr( cfg );
600 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.remote", "origin" ), GIT_OK );
601 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.merge", "refs/heads/main" ), GIT_OK );
602
603 KIGIT_COMMON common( repo );
604 auto [local, remote] = common.GetDifferentFiles();
605
606 BOOST_CHECK( local.empty() );
607 BOOST_CHECK_MESSAGE( remote.find( wxS( "remote_only.txt" ) ) != remote.end(),
608 "remote_only.txt should be reported as BEHIND" );
609}
610
611
612// The commit panels and the advanced window register refresh listeners so a write from any
613// one of them updates the others. A notify must fire every registered listener.
614BOOST_AUTO_TEST_CASE( RefreshListenersFireOnNotify )
615{
616 KIGIT_COMMON common( nullptr );
617
618 int a = 0;
619 int b = 0;
620 int ownerA = 0;
621 int ownerB = 0;
622
623 common.AddRefreshListener( &ownerA,
624 [&]()
625 {
626 a++;
627 } );
628 common.AddRefreshListener( &ownerB,
629 [&]()
630 {
631 b++;
632 } );
633
634 common.NotifyRefreshListeners();
635 BOOST_CHECK_EQUAL( a, 1 );
636 BOOST_CHECK_EQUAL( b, 1 );
637
638 common.NotifyRefreshListeners();
639 BOOST_CHECK_EQUAL( a, 2 );
640 BOOST_CHECK_EQUAL( b, 2 );
641}
642
643
644// A removed listener, such as one whose panel was destroyed, must never fire again.
645BOOST_AUTO_TEST_CASE( RemovedRefreshListenerDoesNotFire )
646{
647 KIGIT_COMMON common( nullptr );
648
649 int a = 0;
650 int b = 0;
651 int ownerA = 0;
652 int ownerB = 0;
653
654 common.AddRefreshListener( &ownerA,
655 [&]()
656 {
657 a++;
658 } );
659 common.AddRefreshListener( &ownerB,
660 [&]()
661 {
662 b++;
663 } );
664 common.RemoveRefreshListener( &ownerA );
665
666 common.NotifyRefreshListeners();
667 BOOST_CHECK_EQUAL( a, 0 );
668 BOOST_CHECK_EQUAL( b, 1 );
669}
670
671
672// A panel torn down while responding to a refresh unregisters from inside its own callback, and
673// the remaining listeners must still run.
674BOOST_AUTO_TEST_CASE( RefreshListenerCanUnregisterDuringNotify )
675{
676 KIGIT_COMMON common( nullptr );
677
678 int a = 0;
679 int b = 0;
680 int ownerA = 0;
681 int ownerB = 0;
682
683 common.AddRefreshListener( &ownerA,
684 [&]()
685 {
686 a++;
687 common.RemoveRefreshListener( &ownerA );
688 } );
689 common.AddRefreshListener( &ownerB,
690 [&]()
691 {
692 b++;
693 } );
694
695 common.NotifyRefreshListeners();
696 BOOST_CHECK_EQUAL( a, 1 );
697 BOOST_CHECK_EQUAL( b, 1 );
698
699 common.NotifyRefreshListeners();
700 BOOST_CHECK_EQUAL( a, 1 );
701 BOOST_CHECK_EQUAL( b, 2 );
702}
703
704
705// A listener removed by an earlier listener in the same round must not run.
706BOOST_AUTO_TEST_CASE( RefreshListenerRemovedByAnotherDoesNotFire )
707{
708 KIGIT_COMMON common( nullptr );
709
710 int a = 0;
711 int b = 0;
712 int ownerA = 0;
713 int ownerB = 0;
714
715 common.AddRefreshListener( &ownerA,
716 [&]()
717 {
718 a++;
719 common.RemoveRefreshListener( &ownerB );
720 } );
721 common.AddRefreshListener( &ownerB,
722 [&]()
723 {
724 b++;
725 } );
726
727 common.NotifyRefreshListeners();
728 BOOST_CHECK_EQUAL( a, 1 );
729 BOOST_CHECK_EQUAL( b, 0 );
730}
731
732
733// The History page reads the log through GetRecentCommits. With no repository it must return
734// an empty list rather than crash.
735BOOST_AUTO_TEST_CASE( GetRecentCommitsEmptyWithoutRepo )
736{
737 KIGIT_COMMON common( nullptr );
738 BOOST_CHECK( common.GetRecentCommits( 10 ).empty() );
739}
740
741
742BOOST_AUTO_TEST_CASE( GetRecentCommitsHonorsAnAlreadyCancelledOperation )
743{
744 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
745 KIGIT_COMMON common( repo.get() );
746 auto lock = common.LockAction();
747
748 BOOST_REQUIRE_EQUAL( common.GetRecentCommits( 10 ).size(), 1u );
749
751 token->store( true );
752
753 {
754 KIGIT_COMMON::OPERATION_GUARD operation( &common, token );
755 BOOST_CHECK( common.GetRecentCommits( 10 ).empty() );
756 }
757
758 // Cancellation belongs to that operation. A later history read must still work.
759 BOOST_CHECK_EQUAL( common.GetRecentCommits( 10 ).size(), 1u );
760}
761
762
763// GetRecentCommits returns commits newest-first with their summary, author and short hash,
764// and honors the requested maximum.
765BOOST_AUTO_TEST_CASE( GetRecentCommitsReturnsNewestFirst )
766{
767 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
768 KIGIT::GitRepositoryPtr repoPtr( repo );
769
770 // Add a second commit on top of the first.
771 {
772 wxFileName fn( TempPath(), wxS( "file.txt" ) );
773 std::ofstream f( fn.GetFullPath().utf8_string() );
774 f << "v2\n";
775 }
776
777 git_index* index = nullptr;
778 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
779 KIGIT::GitIndexPtr indexPtr( index );
780 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "file.txt" ), GIT_OK );
781 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
782
783 git_oid treeOid;
784 BOOST_REQUIRE_EQUAL( git_index_write_tree( &treeOid, index ), GIT_OK );
785
786 git_tree* tree = nullptr;
787 BOOST_REQUIRE_EQUAL( git_tree_lookup( &tree, repo, &treeOid ), GIT_OK );
788 KIGIT::GitTreePtr treePtr( tree );
789
790 git_reference* head = nullptr;
791 BOOST_REQUIRE_EQUAL( git_repository_head( &head, repo ), GIT_OK );
792 KIGIT::GitReferencePtr headPtr( head );
793
794 git_commit* parent = nullptr;
795 BOOST_REQUIRE_EQUAL( git_commit_lookup( &parent, repo, git_reference_target( head ) ), GIT_OK );
796 KIGIT::GitCommitPtr parentPtr( parent );
797
798 git_signature* sig = nullptr;
799 BOOST_REQUIRE_EQUAL( git_signature_now( &sig, "QA Test", "[email protected]" ), GIT_OK );
800 KIGIT::GitSignaturePtr sigPtr( sig );
801
802 const git_commit* parents[1] = { parent };
803 git_oid secondOid;
804 BOOST_REQUIRE_EQUAL( git_commit_create( &secondOid, repo, "HEAD", sig, sig, nullptr, "second", tree, 1, parents ),
805 GIT_OK );
806
807 KIGIT_COMMON common( repo );
808
809 std::vector<KIGIT_COMMIT_INFO> commits = common.GetRecentCommits( 10 );
810 BOOST_REQUIRE_EQUAL( commits.size(), 2u );
811 BOOST_CHECK_EQUAL( commits[0].summary.ToStdString(), "second" );
812 BOOST_CHECK_EQUAL( commits[1].summary.ToStdString(), "initial" );
813 BOOST_CHECK_EQUAL( commits[0].author.ToStdString(), "QA Test" );
814
815 // The hash is abbreviated to 12 characters. A 7-char abbreviation was ambiguous in large
816 // repositories, which made a later revparse of the hash fail.
817 BOOST_CHECK_EQUAL( commits[0].hash.length(), 12u );
818
819 // The maximum is honored.
820 BOOST_CHECK_EQUAL( common.GetRecentCommits( 1 ).size(), 1u );
821 BOOST_CHECK_EQUAL( common.GetRecentCommits( 0 ).size(), 0u );
822}
823
824
825// Git does not check the address itself, so this is only here to catch a typo before every
826// commit carries it.
827BOOST_AUTO_TEST_CASE( IdentityEmailMustLookLikeAnAddress )
828{
829 wxString error;
830
831 BOOST_CHECK( KIGIT::ValidateIdentity( wxT( "A Name" ), wxT( "[email protected]" ), error ) );
832 BOOST_CHECK( KIGIT::ValidateIdentity( wxT( "A Name" ), wxT( "[email protected]" ), error ) );
833
834 BOOST_CHECK( !KIGIT::ValidateIdentity( wxEmptyString, wxT( "[email protected]" ), error ) );
835 BOOST_CHECK( !KIGIT::ValidateIdentity( wxT( " " ), wxT( "[email protected]" ), error ) );
836
837 for( const wxString& bad : { wxT( "a@" ), wxT( "@" ), wxT( "@@" ), wxT( "a@b" ), wxT( "@b.c" ), wxT( "a [email protected]" ),
838 wxT( "[email protected]@d.e" ), wxT( "[email protected]" ), wxT( "a@b." ) } )
839 {
840 BOOST_CHECK_MESSAGE( !KIGIT::ValidateIdentity( wxT( "A Name" ), bad, error ), bad );
841 }
842}
843
844// A project can be open before it is ever put under version control, so a session with no
845// repository is a normal state and not a programming error.
846BOOST_AUTO_TEST_CASE( SessionWithoutARepositoryAnswersQuietly )
847{
848 KIGIT_COMMON common( nullptr );
849
850 BOOST_CHECK( common.GetCurrentBranchName().IsEmpty() );
851 BOOST_CHECK( common.GetProjectDirs().empty() );
852 BOOST_CHECK( !common.HasPushAndPullRemote() );
853 BOOST_CHECK( common.GetRemotename().IsEmpty() );
854
855 BOOST_CHECK_NO_THROW( common.UpdateCurrentBranchInfo() );
856}
857
858// A branch name the user typed is checked before git sees it, so the refusal names the
859// mistake instead of quoting libgit2.
860BOOST_AUTO_TEST_CASE( BranchNameErrorNamesTheMistake )
861{
862 BOOST_CHECK( KIGIT::BranchNameError( wxS( "feature/new-thing" ) ).IsEmpty() );
863 BOOST_CHECK( KIGIT::BranchNameError( wxS( "fix_25074" ) ).IsEmpty() );
864
865 BOOST_CHECK( !KIGIT::BranchNameError( wxEmptyString ).IsEmpty() );
866 BOOST_CHECK( KIGIT::BranchNameError( wxS( "two words" ) ).Contains( wxS( "spaces" ) ) );
867 BOOST_CHECK( KIGIT::BranchNameError( wxS( "-lead" ) ).Contains( wxS( "dash" ) ) );
868 BOOST_CHECK( KIGIT::BranchNameError( wxS( "a..b" ) ).Contains( wxS( "dots" ) ) );
869 BOOST_CHECK( KIGIT::BranchNameError( wxS( "trailing/" ) ).Contains( wxS( "slash" ) ) );
870
871 // Caught by git itself rather than by a named rule.
872 BOOST_CHECK( !KIGIT::BranchNameError( wxS( "with~tilde" ) ).IsEmpty() );
873 BOOST_CHECK( !KIGIT::BranchNameError( wxS( "with:colon" ) ).IsEmpty() );
874 BOOST_CHECK( !KIGIT::BranchNameError( wxS( "ends.lock" ) ).IsEmpty() );
875}
876
877
878// The check belongs to branch creation, not to the one dialog that prompts for a name.
879BOOST_AUTO_TEST_CASE( CreateBranchRefusesAnInvalidName )
880{
881 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
882 KIGIT::GitRepositoryPtr repoPtr( repo );
883
884 KIGIT_COMMON common( repo );
885
886 BOOST_CHECK( !common.CreateBranch( wxS( "two words" ) ) );
887 BOOST_CHECK( !common.CreateBranch( wxS( "a..b" ) ) );
888 BOOST_CHECK( common.CreateBranch( wxS( "feature/ok" ) ) );
889}
890
891// Git stores commit text as opaque bytes. A repository written on Windows can hold ISO 8859-1,
892// and decoding it strictly as UTF-8 would leave the whole row blank.
893BOOST_AUTO_TEST_CASE( CommitTextThatIsNotUtf8StillReads )
894{
895 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
896 KIGIT::GitRepositoryPtr repoPtr( repo );
897
898 git_reference* head = nullptr;
899 BOOST_REQUIRE_EQUAL( git_repository_head( &head, repo ), GIT_OK );
900 KIGIT::GitReferencePtr headPtr( head );
901
902 git_commit* parent = nullptr;
903 BOOST_REQUIRE_EQUAL( git_commit_lookup( &parent, repo, git_reference_target( head ) ), GIT_OK );
904 KIGIT::GitCommitPtr parentPtr( parent );
905
906 git_tree* tree = nullptr;
907 BOOST_REQUIRE_EQUAL( git_commit_tree( &tree, parent ), GIT_OK );
908 KIGIT::GitTreePtr treePtr( tree );
909
910 // "Jorg" and "Cafe fix" with the accented letters as single Latin-1 bytes.
911 const char* latin1Author = "J\xf6rg";
912 const char* latin1Summary = "Caf\xe9 fix";
913
914 git_signature* sig = nullptr;
915 BOOST_REQUIRE_EQUAL( git_signature_now( &sig, latin1Author, "[email protected]" ), GIT_OK );
916 KIGIT::GitSignaturePtr sigPtr( sig );
917
918 const git_commit* parents[1] = { parent };
919 git_oid oid;
920 BOOST_REQUIRE_EQUAL( git_commit_create( &oid, repo, "HEAD", sig, sig, nullptr, latin1Summary, tree, 1, parents ),
921 GIT_OK );
922
923 KIGIT_COMMON common( repo );
924
925 std::vector<KIGIT_COMMIT_INFO> commits = common.GetRecentCommits( 1 );
926 BOOST_REQUIRE_EQUAL( commits.size(), 1u );
927 BOOST_CHECK_EQUAL( commits[0].summary.ToStdString(), wxString::FromUTF8( "Caf\xc3\xa9 fix" ).ToStdString() );
928 BOOST_CHECK_EQUAL( commits[0].author.ToStdString(), wxString::FromUTF8( "J\xc3\xb6rg" ).ToStdString() );
929
930 KIGIT_COMMIT_DETAIL detail = common.GetCommitDetail( commits[0].hash );
931 BOOST_REQUIRE( detail.found );
932 BOOST_CHECK( !detail.message.IsEmpty() );
933 BOOST_CHECK( !detail.author.IsEmpty() );
934}
935
936// The History page can narrow the log to one branch or to a search text. With no branch named
937// the walk covers every branch, so a commit on a side branch shows up too.
938BOOST_AUTO_TEST_CASE( GetRecentCommitsFiltersByBranchAndSearch )
939{
940 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
941 KIGIT::GitRepositoryPtr repoPtr( repo );
942
943 // A side branch with one commit of its own, then back on main for one more.
944 git_oid headOid;
945 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &headOid, repo, "HEAD" ), GIT_OK );
946
947 git_commit* headCommit = nullptr;
948 BOOST_REQUIRE_EQUAL( git_commit_lookup( &headCommit, repo, &headOid ), GIT_OK );
949 KIGIT::GitCommitPtr headPtr( headCommit );
950
951 git_reference* side = nullptr;
952 BOOST_REQUIRE_EQUAL( git_branch_create( &side, repo, "side", headCommit, 0 ), GIT_OK );
953 KIGIT::GitReferencePtr sidePtr( side );
954
955 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo, "refs/heads/side" ), GIT_OK );
956 writeFile( TempPath(), "side.txt", "side\n" );
957 commitWorkingFile( repo, "side.txt", "side work" );
958
959 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo, "refs/heads/main" ), GIT_OK );
960 writeFile( TempPath(), "file.txt", "v2\n" );
961 commitWorkingFile( repo, "file.txt", "main work" );
962
963 KIGIT_COMMON common( repo );
964
965 auto summaries = []( const std::vector<KIGIT_COMMIT_INFO>& aCommits )
966 {
967 std::vector<std::string> out;
968
969 for( const KIGIT_COMMIT_INFO& c : aCommits )
970 out.push_back( c.summary.ToStdString() );
971
972 return out;
973 };
974
975 // Every branch: the side commit is there along with main's.
976 std::vector<std::string> all = summaries( common.GetRecentCommits( 10 ) );
977 BOOST_REQUIRE_EQUAL( all.size(), 3u );
978 BOOST_CHECK( std::find( all.begin(), all.end(), "side work" ) != all.end() );
979 BOOST_CHECK( std::find( all.begin(), all.end(), "main work" ) != all.end() );
980
981 // One branch: only what it reaches.
983 onMain.branch = wxS( "main" );
984 std::vector<std::string> mainOnly = summaries( common.GetRecentCommits( 10, onMain ) );
985 BOOST_REQUIRE_EQUAL( mainOnly.size(), 2u );
986 BOOST_CHECK_EQUAL( mainOnly[0], "main work" );
987 BOOST_CHECK_EQUAL( mainOnly[1], "initial" );
988
989 // Search matches the summary, case insensitively, and the hash.
991 search.search = wxS( "SIDE" );
992 std::vector<KIGIT_COMMIT_INFO> found = common.GetRecentCommits( 10, search );
993 BOOST_REQUIRE_EQUAL( found.size(), 1u );
994 BOOST_CHECK_EQUAL( found[0].summary.ToStdString(), "side work" );
995
996 search.search = found[0].hash.Left( 6 );
997 BOOST_REQUIRE_EQUAL( common.GetRecentCommits( 10, search ).size(), 1u );
998
999 search.search = wxS( "nothing like this" );
1000 BOOST_CHECK( common.GetRecentCommits( 10, search ).empty() );
1001}
1002
1003
1004// Each commit carries the names of the refs that point at it, so the list can show where HEAD,
1005// the branches and the tags are.
1006BOOST_AUTO_TEST_CASE( GetRecentCommitsNamesRefsOnEachCommit )
1007{
1008 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1009 KIGIT::GitRepositoryPtr repoPtr( repo );
1010
1011 git_oid firstOid;
1012 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &firstOid, repo, "HEAD" ), GIT_OK );
1013
1014 git_object* first = nullptr;
1015 BOOST_REQUIRE_EQUAL( git_object_lookup( &first, repo, &firstOid, GIT_OBJECT_COMMIT ), GIT_OK );
1016 KIGIT::GitObjectPtr firstPtr( first );
1017
1018 git_oid tagOid;
1019 BOOST_REQUIRE_EQUAL( git_tag_create_lightweight( &tagOid, repo, "v1.0", first, 0 ), GIT_OK );
1020
1021 writeFile( TempPath(), "file.txt", "v2\n" );
1022 commitWorkingFile( repo, "file.txt", "second" );
1023
1024 KIGIT_COMMON common( repo );
1025
1026 std::vector<KIGIT_COMMIT_INFO> commits = common.GetRecentCommits( 10 );
1027 BOOST_REQUIRE_EQUAL( commits.size(), 2u );
1028
1029 // The newest commit is HEAD and main. The first one carries only the tag.
1030 BOOST_REQUIRE_EQUAL( commits[0].refs.size(), 2u );
1031 BOOST_CHECK_EQUAL( commits[0].refs[0].ToStdString(), "HEAD" );
1032 BOOST_CHECK_EQUAL( commits[0].refs[1].ToStdString(), "main" );
1033
1034 BOOST_REQUIRE_EQUAL( commits[1].refs.size(), 1u );
1035 BOOST_CHECK_EQUAL( commits[1].refs[0].ToStdString(), "v1.0" );
1036}
1037
1038
1039// The detail pane shows one commit with its message, parent and per-file line counts.
1040// A project just put under version control has no commits. There is nothing to branch from, so
1041// naming a branch means pointing HEAD at it and letting the first commit create it.
1042BOOST_AUTO_TEST_CASE( StartUnbornBranchNamesTheBranchTheFirstCommitWillCreate )
1043{
1044 git_repository* repo = nullptr;
1045 git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT;
1046 opts.flags = GIT_REPOSITORY_INIT_MKPATH;
1047 opts.initial_head = "main";
1048
1049 BOOST_REQUIRE_EQUAL( git_repository_init_ext( &repo, TempPath().utf8_string().c_str(), &opts ), GIT_OK );
1050 KIGIT::GitRepositoryPtr repoPtr( repo );
1051
1052 KIGIT_COMMON common( repo );
1053
1054 BOOST_REQUIRE( common.IsHeadUnborn() );
1055 BOOST_CHECK( common.GetBranches().empty() );
1056 BOOST_CHECK( !common.HasLocalCommits() );
1057 BOOST_CHECK( !common.HasUpstreamCommits() );
1058 BOOST_CHECK( !common.GetUnmergedCommitCount( "main" ) );
1059 BOOST_CHECK( !common.GetBranchDetail( "main" ).divergence );
1060
1061 // There is no commit to start a branch at, which is why the method below exists.
1062 BOOST_CHECK( !common.CreateBranch( wxS( "rev-a" ) ) );
1063
1064 // A name git itself would refuse must be refused here too, or the first commit creates a
1065 // branch no git tool can name.
1066 BOOST_CHECK( !common.StartUnbornBranch( wxS( "-leading" ) ) );
1067 BOOST_CHECK( !common.StartUnbornBranch( wxS( "has space" ) ) );
1068 BOOST_CHECK( !common.StartUnbornBranch( wxS( "bad..name" ) ) );
1069
1070 BOOST_REQUIRE( common.StartUnbornBranch( wxS( "rev-a" ) ) );
1071
1072 git_reference* head = nullptr;
1073 BOOST_REQUIRE_EQUAL( git_reference_lookup( &head, repo, "HEAD" ), GIT_OK );
1074 KIGIT::GitReferencePtr headPtr( head );
1075
1076 BOOST_CHECK_EQUAL( std::string( git_reference_symbolic_target( head ) ), "refs/heads/rev-a" );
1077 BOOST_CHECK( common.IsHeadUnborn() );
1078 BOOST_CHECK( common.GetBranches().empty() );
1079}
1080
1081
1082// The History page shows a commit's whole message in its detail pane, summary line and body.
1083BOOST_AUTO_TEST_CASE( GetCommitDetailCarriesTheWholeMessage )
1084{
1085 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1086 KIGIT::GitRepositoryPtr repoPtr( repo );
1087
1088 writeFile( TempPath(), "file.txt", "v2\n" );
1089 commitWorkingFile( repo, "file.txt", "Route the power rails\n\nBody line one.\nBody line two.\n" );
1090
1091 KIGIT_COMMON common( repo );
1092 KIGIT_COMMIT_DETAIL detail = common.GetCommitDetail( common.GetRecentCommits( 1 )[0].hash );
1093
1094 BOOST_REQUIRE( detail.found );
1095 BOOST_CHECK_EQUAL( detail.message.ToStdString(), "Route the power rails\n\nBody line one.\nBody line two." );
1096}
1097
1098
1099BOOST_AUTO_TEST_CASE( GetCommitDetailCountsLinesPerFile )
1100{
1101 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "one\ntwo\nthree\n" );
1102 KIGIT::GitRepositoryPtr repoPtr( repo );
1103
1104 // Change one line of the existing file and add a new file in the same commit.
1105 writeFile( TempPath(), "file.txt", "one\n2\nthree\n" );
1106 writeFile( TempPath(), "new.txt", "a\nb\n" );
1107
1108 {
1109 git_index* index = nullptr;
1110 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
1111 KIGIT::GitIndexPtr indexPtr( index );
1112 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "new.txt" ), GIT_OK );
1113 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
1114 }
1115
1116 commitWorkingFile( repo, "file.txt", "edit and add\n\nWith a body line." );
1117
1118 KIGIT_COMMON common( repo );
1119
1120 std::vector<KIGIT_COMMIT_INFO> commits = common.GetRecentCommits( 10 );
1121 BOOST_REQUIRE_EQUAL( commits.size(), 2u );
1122
1123 KIGIT_COMMIT_DETAIL detail = common.GetCommitDetail( commits[0].hash );
1124 BOOST_REQUIRE( detail.found );
1125 BOOST_CHECK_EQUAL( detail.shortHash.ToStdString(), commits[0].hash.ToStdString() );
1126 BOOST_CHECK_EQUAL( detail.hash.length(), 40u );
1127 BOOST_CHECK_EQUAL( detail.message.ToStdString(), "edit and add\n\nWith a body line." );
1128 BOOST_CHECK_EQUAL( detail.author.ToStdString(), "QA Test" );
1129 BOOST_REQUIRE_EQUAL( detail.parents.size(), 1u );
1130 BOOST_CHECK_EQUAL( detail.parents[0].ToStdString(), commits[1].hash.ToStdString() );
1131 BOOST_CHECK_EQUAL( detail.refs.size(), 2u );
1132
1133 BOOST_REQUIRE_EQUAL( detail.files.size(), 2u );
1134 BOOST_CHECK_EQUAL( detail.files[0].path.ToStdString(), "file.txt" );
1135 BOOST_CHECK( detail.files[0].status == KIGIT::FILE_CHANGE_STATUS::MODIFIED );
1136 BOOST_CHECK_EQUAL( detail.files[0].added, 1 );
1137 BOOST_CHECK_EQUAL( detail.files[0].removed, 1 );
1138 BOOST_CHECK_EQUAL( detail.files[1].path.ToStdString(), "new.txt" );
1139 BOOST_CHECK( detail.files[1].status == KIGIT::FILE_CHANGE_STATUS::ADDED );
1140 BOOST_CHECK_EQUAL( detail.files[1].added, 2 );
1141 BOOST_CHECK_EQUAL( detail.files[1].removed, 0 );
1142 BOOST_CHECK_EQUAL( detail.added, 3 );
1143 BOOST_CHECK_EQUAL( detail.removed, 1 );
1144
1145 // A root commit has no parent, so everything it holds counts as added.
1146 KIGIT_COMMIT_DETAIL root = common.GetCommitDetail( commits[1].hash );
1147 BOOST_REQUIRE( root.found );
1148 BOOST_CHECK( root.parents.empty() );
1149 BOOST_REQUIRE_EQUAL( root.files.size(), 1u );
1150 BOOST_CHECK( root.files[0].status == KIGIT::FILE_CHANGE_STATUS::ADDED );
1151 BOOST_CHECK_EQUAL( root.files[0].added, 3 );
1152
1153 BOOST_CHECK( !common.GetCommitDetail( wxS( "0000000000ab" ) ).found );
1154}
1155
1156
1157// The changes list leaves out other projects in the repository, and KiCad's own lock and
1158// backup files. The line counts sit beside that list, so they must cover the same files.
1159BOOST_AUTO_TEST_CASE( WorkingTreeLineStatsCountOnlyTheFilesGiven )
1160{
1161 git_repository* repo = makeRepoWithCommit( TempPath(), "readme.txt", "root\n" );
1162 KIGIT::GitRepositoryPtr repoPtr( repo );
1163
1164 BOOST_REQUIRE( wxFileName::Mkdir( TempPath() + wxS( "/mine" ) ) );
1165 BOOST_REQUIRE( wxFileName::Mkdir( TempPath() + wxS( "/theirs" ) ) );
1166 writeFile( TempPath(), wxS( "mine/board.txt" ), "one\ntwo\n" );
1167 writeFile( TempPath(), wxS( "theirs/board.txt" ), "one\ntwo\n" );
1168
1169 {
1170 git_index* index = nullptr;
1171 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
1172 KIGIT::GitIndexPtr indexPtr( index );
1173 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "mine/board.txt" ), GIT_OK );
1174 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "theirs/board.txt" ), GIT_OK );
1175 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
1176 }
1177
1178 commitWorkingFile( repo, wxS( "readme.txt" ), "two projects\n" );
1179
1180 // One line added in this project, three in the other, and an untracked lock file.
1181 writeFile( TempPath(), wxS( "mine/board.txt" ), "one\ntwo\nthree\n" );
1182 writeFile( TempPath(), wxS( "theirs/board.txt" ), "one\ntwo\na\nb\nc\n" );
1183 writeFile( TempPath(), wxS( "mine/~board.lck" ), "locked\n" );
1184
1185 KIGIT_COMMON common( repo );
1186
1187 std::pair<int, int> mine = common.GetWorkingTreeLineStats( { wxS( "mine/board.txt" ) } );
1188 BOOST_CHECK_EQUAL( mine.first, 1 );
1189 BOOST_CHECK_EQUAL( mine.second, 0 );
1190
1191 // A path that did not change contributes nothing, and an unlisted one is not counted.
1192 std::pair<int, int> none = common.GetWorkingTreeLineStats( { wxS( "readme.txt" ) } );
1193 BOOST_CHECK_EQUAL( none.first, 0 );
1194 BOOST_CHECK_EQUAL( none.second, 0 );
1195}
1196
1197
1198#ifndef __WINDOWS__
1199// A project can be opened through a symlink pointing straight at its folder inside the
1200// repository. Stepping the visible path up one component then lands outside the repository
1201// altogether, so the folder has to be worked out from the resolved paths instead.
1202BOOST_AUTO_TEST_CASE( SymlinkedProjectFolderStillFindsItsPrefix )
1203{
1204 wxString repoRoot = TempPath() + wxS( "/real/repo" );
1205 BOOST_REQUIRE( wxFileName::Mkdir( repoRoot + wxS( "/proj" ), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL ) );
1206 writeFile( repoRoot, wxS( "proj/p.kicad_pro" ), "{}\n" );
1207
1208 wxString deepLink = TempPath() + wxS( "/deep" );
1209 BOOST_REQUIRE_EQUAL( symlink( ( repoRoot + wxS( "/proj" ) ).utf8_string().c_str(), deepLink.utf8_string().c_str() ),
1210 0 );
1211
1212 wxString rootLink = TempPath() + wxS( "/top" );
1213 BOOST_REQUIRE_EQUAL( symlink( repoRoot.utf8_string().c_str(), rootLink.utf8_string().c_str() ), 0 );
1214
1215 const wxString canonicalWorkDir = repoRoot + wxS( "/" );
1216
1217 // Pointed at the project folder there is no visible path for the repository root, so the
1218 // resolved one is used.
1219 wxString deepProject = deepLink + wxS( "/" );
1220 wxString deepWorkDir = KIGIT::PROJECT_GIT_UTILS::ComputeSymlinkPreservingWorkDir( deepProject, canonicalWorkDir );
1221 BOOST_CHECK_EQUAL( deepWorkDir.ToStdString(), canonicalWorkDir.ToStdString() );
1222 std::optional<wxString> deepPrefix = KIGIT::PROJECT_GIT_UTILS::ProjectPrefix( deepWorkDir, deepProject );
1223 BOOST_REQUIRE( deepPrefix );
1224 BOOST_CHECK_EQUAL( deepPrefix->ToStdString(), "proj/" );
1225 BOOST_CHECK( KIGIT::PROJECT_GIT_UTILS::IsWithinProjectPath( repoRoot + wxS( "/proj/p.kicad_pro" ), deepProject ) );
1226 BOOST_CHECK( !KIGIT::PROJECT_GIT_UTILS::IsWithinProjectPath( repoRoot + wxS( "/elsewhere/x.txt" ), deepProject ) );
1227
1228 // Pointed at the repository root the visible path still stands for it, so it is kept.
1229 wxString topProject = rootLink + wxS( "/proj/" );
1230 wxString topWorkDir = KIGIT::PROJECT_GIT_UTILS::ComputeSymlinkPreservingWorkDir( topProject, canonicalWorkDir );
1231 BOOST_CHECK_EQUAL( topWorkDir.ToStdString(), ( rootLink + wxS( "/" ) ).ToStdString() );
1232 std::optional<wxString> topPrefix = KIGIT::PROJECT_GIT_UTILS::ProjectPrefix( topWorkDir, topProject );
1233 BOOST_REQUIRE( topPrefix );
1234 BOOST_CHECK_EQUAL( topPrefix->ToStdString(), "proj/" );
1235}
1236#endif
1237
1238
1239// WriteBlobToFile materializes a file's committed content, used to feed the diff viewer.
1240BOOST_AUTO_TEST_CASE( WriteBlobToFileExtractsCommittedContent )
1241{
1242 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "hello world\n" );
1243 KIGIT::GitRepositoryPtr repoPtr( repo );
1244
1245 wxString dest = TempPath() + wxFileName::GetPathSeparator() + wxS( "extracted.txt" );
1246
1247 BOOST_CHECK( KIGIT::PROJECT_GIT_UTILS::WriteBlobToFile( repo, wxS( "HEAD" ), wxS( "file.txt" ), dest ) );
1248
1249 wxFile in( dest );
1250 wxString content;
1251 BOOST_REQUIRE( in.IsOpened() );
1252 in.ReadAll( &content );
1253 BOOST_CHECK_EQUAL( content.ToStdString(), "hello world\n" );
1254
1255 // A path that is not in the tree returns false and leaves the destination alone.
1256 BOOST_CHECK( !KIGIT::PROJECT_GIT_UTILS::WriteBlobToFile( repo, wxS( "HEAD" ), wxS( "missing.txt" ), dest ) );
1257 BOOST_CHECK_EQUAL( readWorkingFile( wxFileName( dest ) ), "hello world\n" );
1258}
1259
1260
1261BOOST_AUTO_TEST_CASE( DiffWorkspaceKeepsExtractionsSeparateAndRemovesThem )
1262{
1263 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "board.kicad_pcb", "board version\n" ) );
1264 writeFile( TempPath(), "schematics/root.kicad_sch", "root sheet\n" );
1265 commitWorkingFile( repo.get(), "schematics/root.kicad_sch", "add root sheet" );
1266 writeFile( TempPath(), "schematics/child.kicad_sch", "child sheet\n" );
1267 commitWorkingFile( repo.get(), "schematics/child.kicad_sch", "add child sheet" );
1268
1269 wxString root;
1270 wxString otherRoot;
1271 wxString boardFile;
1272 wxString schematicFile;
1273
1274 {
1275 GIT_DIFF_WORKSPACE workspace;
1276 BOOST_REQUIRE( workspace.IsOk() );
1277 root = workspace.Root();
1278 boardFile = workspace.Materialize( repo.get(), "HEAD", "board.kicad_pcb", KICAD_DIFF::DOC_KIND::PCB );
1279 BOOST_REQUIRE( !boardFile.IsEmpty() );
1280 BOOST_CHECK_EQUAL( readWorkingFile( wxFileName( boardFile ) ), "board version\n" );
1281
1282 {
1283 GIT_DIFF_WORKSPACE other;
1284 BOOST_REQUIRE( other.IsOk() );
1285 otherRoot = other.Root();
1286 BOOST_CHECK( root != otherRoot );
1287 wxString otherBoard = other.Materialize( repo.get(), "HEAD", "board.kicad_pcb", KICAD_DIFF::DOC_KIND::PCB );
1288 BOOST_REQUIRE( !otherBoard.IsEmpty() );
1289 BOOST_CHECK( boardFile != otherBoard );
1290 BOOST_CHECK_EQUAL( readWorkingFile( wxFileName( otherBoard ) ), "board version\n" );
1291 }
1292
1293 BOOST_CHECK( !wxFileName::DirExists( otherRoot ) );
1294 BOOST_CHECK_EQUAL( readWorkingFile( wxFileName( boardFile ) ), "board version\n" );
1295 BOOST_CHECK( workspace.Materialize( repo.get(), "missing-ref", "board.kicad_pcb", KICAD_DIFF::DOC_KIND::PCB )
1296 .IsEmpty() );
1297
1298 // A schematic extraction includes the child sheets beside its root sheet.
1299 schematicFile =
1300 workspace.Materialize( repo.get(), "HEAD", "schematics/root.kicad_sch", KICAD_DIFF::DOC_KIND::SCH );
1301 BOOST_REQUIRE( !schematicFile.IsEmpty() );
1302 BOOST_CHECK_EQUAL( readWorkingFile( wxFileName( schematicFile ) ), "root sheet\n" );
1303 wxFileName childFile( wxFileName( schematicFile ).GetPath(), wxS( "child.kicad_sch" ) );
1304 BOOST_CHECK_EQUAL( readWorkingFile( childFile ), "child sheet\n" );
1305 }
1306
1307 BOOST_CHECK( !wxFileName::DirExists( root ) );
1308 BOOST_CHECK( !wxFileName::FileExists( boardFile ) );
1309 BOOST_CHECK( !wxFileName::FileExists( schematicFile ) );
1310}
1311
1312
1313// GetTags resolves each tag to the commit it points at.
1314BOOST_AUTO_TEST_CASE( GetTagsResolvesTagToCommit )
1315{
1316 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1317 KIGIT::GitRepositoryPtr repoPtr( repo );
1318
1319 KIGIT_COMMON emptyCommon( nullptr );
1320 BOOST_CHECK( emptyCommon.GetTags().empty() );
1321
1322 KIGIT_COMMON common( repo );
1323 BOOST_CHECK( common.GetTags().empty() );
1324
1325 git_object* target = nullptr;
1326 BOOST_REQUIRE_EQUAL( git_revparse_single( &target, repo, "HEAD" ), GIT_OK );
1327 KIGIT::GitObjectPtr targetPtr( target );
1328
1329 git_oid tagOid;
1330 BOOST_REQUIRE_EQUAL( git_tag_create_lightweight( &tagOid, repo, "v1.0", target, 0 ), GIT_OK );
1331
1332 std::vector<KIGIT_TAG_INFO> tags = common.GetTags();
1333 BOOST_REQUIRE_EQUAL( tags.size(), 1u );
1334 BOOST_CHECK_EQUAL( tags[0].name.ToStdString(), "v1.0" );
1335 BOOST_CHECK_EQUAL( tags[0].summary.ToStdString(), "initial" );
1336 BOOST_CHECK_EQUAL( tags[0].hash, common.GetRecentCommits( 1 )[0].hash );
1337}
1338
1339
1340// GetStashes lists the repository's stashes.
1341BOOST_AUTO_TEST_CASE( GetStashesListsStash )
1342{
1343 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1344 KIGIT::GitRepositoryPtr repoPtr( repo );
1345
1346 KIGIT_COMMON common( repo );
1347 BOOST_CHECK( common.GetStashes().empty() );
1348
1349 // Dirty the working tree so there is something to stash.
1350 {
1351 wxFileName fn( TempPath(), wxS( "file.txt" ) );
1352 std::ofstream f( fn.GetFullPath().utf8_string() );
1353 f << "v2\n";
1354 }
1355
1356 git_signature* sig = nullptr;
1357 BOOST_REQUIRE_EQUAL( git_signature_now( &sig, "QA Test", "[email protected]" ), GIT_OK );
1358 KIGIT::GitSignaturePtr sigPtr( sig );
1359
1360 git_oid stashOid;
1361 BOOST_REQUIRE_EQUAL( git_stash_save( &stashOid, repo, sig, "work in progress", GIT_STASH_DEFAULT ), GIT_OK );
1362
1363 std::vector<KIGIT_STASH_INFO> stashes = common.GetStashes();
1364 BOOST_REQUIRE_EQUAL( stashes.size(), 1u );
1365 BOOST_CHECK_EQUAL( stashes[0].index, 0u );
1366 BOOST_CHECK( stashes[0].message.Contains( "work in progress" ) );
1367}
1368
1369
1370// A new stash takes index 0 and pushes the rest down, so anything holding an index from an
1371// earlier listing would act on the wrong stash. The id stays with its own stash.
1372BOOST_AUTO_TEST_CASE( StashIdStaysWithItsStashWhenAnotherIsAdded )
1373{
1374 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1375 KIGIT::GitRepositoryPtr repoPtr( repo );
1376
1377 KIGIT_COMMON common( repo );
1378
1379 auto dirtyAndStash = [&]( const char* aContent, const wxString& aMessage )
1380 {
1381 wxFileName fn( TempPath(), wxS( "file.txt" ) );
1382
1383 {
1384 std::ofstream f( fn.GetFullPath().utf8_string() );
1385 f << aContent;
1386 }
1387
1388 BOOST_REQUIRE( common.StashSave( aMessage ) );
1389 };
1390
1391 dirtyAndStash( "v2\n", wxS( "first" ) );
1392
1393 std::vector<KIGIT_STASH_INFO> before = common.GetStashes();
1394 BOOST_REQUIRE_EQUAL( before.size(), 1u );
1395
1396 wxString firstId = before[0].id;
1397 BOOST_REQUIRE( !firstId.IsEmpty() );
1398
1399 dirtyAndStash( "v3\n", wxS( "second" ) );
1400
1401 std::vector<KIGIT_STASH_INFO> after = common.GetStashes();
1402 BOOST_REQUIRE_EQUAL( after.size(), 2u );
1403
1404 // Index 0 is now a different stash than it was.
1405 BOOST_CHECK( after[0].id != firstId );
1406
1407 // The first stash is still there, one place further down, under the same id.
1408 BOOST_CHECK_EQUAL( after[1].id.ToStdString(), firstId.ToStdString() );
1409}
1410
1411
1412// StashSave puts the committed content back, and the stash restores the edits afterwards.
1413BOOST_AUTO_TEST_CASE( StashSaveRoundTrip )
1414{
1415 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1416 KIGIT::GitRepositoryPtr repoPtr( repo );
1417
1418 KIGIT_COMMON common( repo );
1419
1420 writeFile( TempPath(), "file.txt", "v2\n" );
1421 BOOST_REQUIRE( common.StashSave( wxT( "set aside for pull" ) ) );
1422
1423 wxFileName fn( TempPath(), wxS( "file.txt" ) );
1424 BOOST_CHECK_EQUAL( readWorkingFile( fn ), "v1\n" );
1425
1426 std::vector<KIGIT_STASH_INFO> stashes = common.GetStashes();
1427 BOOST_REQUIRE_EQUAL( stashes.size(), 1u );
1428 BOOST_CHECK( stashes[0].message.Contains( "set aside for pull" ) );
1429
1430 BOOST_CHECK( common.StashPop( 0 ) );
1431 BOOST_CHECK_EQUAL( readWorkingFile( fn ), "v2\n" );
1432 BOOST_CHECK( common.GetStashes().empty() );
1433}
1434
1435
1436// CreateBranch and DeleteBranch add and remove a local branch.
1437BOOST_AUTO_TEST_CASE( CreateAndDeleteBranch )
1438{
1439 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1440 KIGIT::GitRepositoryPtr repoPtr( repo );
1441
1442 KIGIT_COMMON common( repo );
1443
1444 std::vector<KIGIT_BRANCH_INFO> branches = common.GetBranches();
1445 BOOST_REQUIRE_EQUAL( branches.size(), 1u );
1446 wxString original = branches[0].name;
1447
1448 BOOST_CHECK( common.CreateBranch( "feature" ) );
1449 branches = common.GetBranches();
1450 BOOST_REQUIRE_EQUAL( branches.size(), 2u );
1451 BOOST_CHECK( std::any_of( branches.begin(), branches.end(),
1452 []( const KIGIT_BRANCH_INFO& aBranch )
1453 {
1454 return aBranch.name == wxS( "feature" ) && !aBranch.isRemote;
1455 } ) );
1456
1457 BOOST_CHECK( common.DeleteBranch( "feature" ) );
1458 branches = common.GetBranches();
1459 BOOST_REQUIRE_EQUAL( branches.size(), 1u );
1460 BOOST_CHECK_EQUAL( branches[0].name, original );
1461}
1462
1463
1464// CreateTag and DeleteTag add and remove a lightweight tag.
1465BOOST_AUTO_TEST_CASE( CreateAndDeleteTag )
1466{
1467 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1468 KIGIT::GitRepositoryPtr repoPtr( repo );
1469
1470 KIGIT_COMMON common( repo );
1471
1472 BOOST_CHECK( common.CreateTag( "v1.0" ) );
1473
1474 std::vector<KIGIT_TAG_INFO> tags = common.GetTags();
1475 BOOST_REQUIRE_EQUAL( tags.size(), 1u );
1476 BOOST_CHECK_EQUAL( tags[0].name.ToStdString(), "v1.0" );
1477
1478 BOOST_CHECK( common.DeleteTag( "v1.0" ) );
1479 BOOST_CHECK( common.GetTags().empty() );
1480}
1481
1482
1483// SetUserConfig writes user.name and user.email to the repository config.
1484BOOST_AUTO_TEST_CASE( SetUserConfigPersists )
1485{
1486 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1487 KIGIT::GitRepositoryPtr repoPtr( repo );
1488
1489 KIGIT_COMMON common( repo );
1490
1491 BOOST_CHECK( common.SetUserConfig( "New Name", "[email protected]" ) );
1492
1493 git_config* config = nullptr;
1494 BOOST_REQUIRE_EQUAL( git_repository_config( &config, repo ), GIT_OK );
1495 KIGIT::GitConfigPtr configPtr( config );
1496
1497 git_config_entry* nameEntry = nullptr;
1498 BOOST_REQUIRE_EQUAL( git_config_get_entry( &nameEntry, config, "user.name" ), GIT_OK );
1499 BOOST_CHECK_EQUAL( wxString::FromUTF8( nameEntry->value ).ToStdString(), "New Name" );
1500 git_config_entry_free( nameEntry );
1501
1502 git_config_entry* emailEntry = nullptr;
1503 BOOST_REQUIRE_EQUAL( git_config_get_entry( &emailEntry, config, "user.email" ), GIT_OK );
1504 BOOST_CHECK_EQUAL( wxString::FromUTF8( emailEntry->value ).ToStdString(), "[email protected]" );
1505 git_config_entry_free( emailEntry );
1506}
1507
1508
1509BOOST_AUTO_TEST_CASE( GitIdentityUsesRepositoryValuesBeforeCustomDefaults )
1510{
1512 KI_TEST::InitGitRepository( TempPath(), "main", "Repository Author", "[email protected]" );
1513 KIGIT_COMMON common( repo.get() );
1514 auto lock = common.LockAction();
1515
1516 git_config* config = nullptr;
1517 BOOST_REQUIRE_EQUAL( git_repository_config( &config, repo.get() ), GIT_OK );
1518 KIGIT::GitConfigPtr configPtr( config );
1519
1520 // Keep the user's global Git identity out of this test.
1521 git_config* local = nullptr;
1522 BOOST_REQUIRE_EQUAL( git_config_open_level( &local, config, GIT_CONFIG_LEVEL_LOCAL ), GIT_OK );
1523 KIGIT::GitConfigPtr localPtr( local );
1524 BOOST_REQUIRE_EQUAL( git_repository_set_config( repo.get(), local ), GIT_OK );
1525
1526 COMMON_SETTINGS::GIT preferences{};
1527 preferences.useDefaultAuthor = false;
1528 preferences.authorName = wxS( "Custom Author" );
1529 preferences.authorEmail = wxS( "[email protected]" );
1530
1531 KIGIT::GIT_IDENTITY identity = KIGIT::ReadGitIdentity( common );
1532 BOOST_CHECK( identity.configured );
1533 GitUserConfig resolved = KIGIT::ResolveGitIdentity( identity, preferences, wxS( "OS User" ) );
1534 BOOST_CHECK_EQUAL( resolved.authorName, wxS( "Repository Author" ) );
1535 BOOST_CHECK_EQUAL( resolved.authorEmail, wxS( "[email protected]" ) );
1536
1537 BOOST_REQUIRE_EQUAL( git_config_delete_entry( local, "user.email" ), GIT_OK );
1538 identity = KIGIT::ReadGitIdentity( common );
1539 BOOST_CHECK( !identity.configured );
1540 resolved = KIGIT::ResolveGitIdentity( identity, preferences, wxS( "OS User" ) );
1541 BOOST_CHECK_EQUAL( resolved.authorName, wxS( "Repository Author" ) );
1542 BOOST_CHECK_EQUAL( resolved.authorEmail, wxS( "[email protected]" ) );
1543
1544 BOOST_REQUIRE_EQUAL( git_config_delete_entry( local, "user.name" ), GIT_OK );
1545 identity = KIGIT::ReadGitIdentity( common );
1546 BOOST_CHECK( !identity.configured );
1547 resolved = KIGIT::ResolveGitIdentity( identity, preferences, wxS( "OS User" ) );
1548 BOOST_CHECK_EQUAL( resolved.authorName, wxS( "Custom Author" ) );
1549 BOOST_CHECK_EQUAL( resolved.authorEmail, wxS( "[email protected]" ) );
1550
1551 // Default mode must not revive custom values left in Preferences.
1552 preferences.useDefaultAuthor = true;
1553 resolved = KIGIT::ResolveGitIdentity( identity, preferences, wxS( "OS User" ) );
1554 BOOST_CHECK_EQUAL( resolved.authorName, wxS( "OS User" ) );
1555 BOOST_CHECK( resolved.authorEmail.IsEmpty() );
1556
1557 preferences.useDefaultAuthor = false;
1558 preferences.authorName.clear();
1559 resolved = KIGIT::ResolveGitIdentity( identity, preferences, wxS( "OS User" ) );
1560 BOOST_CHECK_EQUAL( resolved.authorName, wxS( "OS User" ) );
1561 BOOST_CHECK_EQUAL( resolved.authorEmail, wxS( "[email protected]" ) );
1562}
1563
1564
1565// StashDrop removes a stash from the list.
1566BOOST_AUTO_TEST_CASE( StashDropRemovesStash )
1567{
1568 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1569 KIGIT::GitRepositoryPtr repoPtr( repo );
1570
1571 KIGIT_COMMON common( repo );
1572
1573 writeFile( TempPath(), "file.txt", "v2\n" );
1574 BOOST_REQUIRE( common.StashSave( wxS( "work in progress" ) ) );
1575
1576 BOOST_REQUIRE_EQUAL( common.GetStashes().size(), 1u );
1577 BOOST_CHECK( common.StashDrop( 0 ) );
1578 BOOST_CHECK( common.GetStashes().empty() );
1579}
1580
1581
1582// Apply leaves the stash in the list. Pop removes it.
1583BOOST_AUTO_TEST_CASE( StashApplyKeepsAndStashPopRemoves )
1584{
1585 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1586 KIGIT::GitRepositoryPtr repoPtr( repo );
1587
1588 KIGIT_COMMON common( repo );
1589
1590 writeFile( TempPath(), "file.txt", "v2\n" );
1591 BOOST_REQUIRE( common.StashSave( wxS( "wip" ) ) );
1592 BOOST_REQUIRE_EQUAL( common.GetStashes().size(), 1u );
1593
1594 // Apply keeps the stash in the list.
1595 BOOST_CHECK( common.StashApply( 0 ) );
1596 BOOST_CHECK_EQUAL( common.GetStashes().size(), 1u );
1597
1598 // Applying needs a clean tree, so put the applied change away before popping.
1599 wxString error;
1600 BOOST_REQUIRE( common.DiscardChanges( { wxS( "file.txt" ) }, error ) );
1601
1602 // Pop removes it.
1603 BOOST_CHECK( common.StashPop( 0 ) );
1604 BOOST_CHECK( common.GetStashes().empty() );
1605}
1606
1607
1608// libgit2 reports a conflicting apply as success, so pop would drop the only copy of the work.
1609BOOST_AUTO_TEST_CASE( StashPopKeepsStashWhenItConflicts )
1610{
1611 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1612 KIGIT::GitRepositoryPtr repoPtr( repo );
1613 KIGIT_COMMON common( repo );
1614
1615 wxFileName fn( TempPath(), wxS( "file.txt" ) );
1616
1617 writeFile( TempPath(), "file.txt", "stashed\n" );
1618 BOOST_REQUIRE( common.StashSave( wxS( "wip" ) ) );
1619 BOOST_REQUIRE_EQUAL( common.GetStashes().size(), 1u );
1620
1621 // A different change to the same line, committed, so the stash no longer applies cleanly.
1622 writeFile( TempPath(), "file.txt", "committed\n" );
1623 commitWorkingFile( repo, wxS( "file.txt" ), "conflicting change" );
1624
1625 BOOST_CHECK( !common.StashPop( 0 ) );
1626 BOOST_CHECK_EQUAL( common.GetStashes().size(), 1u );
1627 BOOST_CHECK_EQUAL( readWorkingFile( fn ), "committed\n" );
1628
1629 git_index* index = nullptr;
1630 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
1631 KIGIT::GitIndexPtr indexPtr( index );
1632 BOOST_CHECK( !git_index_has_conflicts( index ) );
1633}
1634
1635
1636// Whether the project rebases when getting changes is read with git's own bool parser, so every
1637// spelling git accepts counts.
1638BOOST_AUTO_TEST_CASE( PullRebaseConfigIsReadAsGitReadsIt )
1639{
1640 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1641 KIGIT::GitRepositoryPtr repoPtr( repo );
1642
1643 KIGIT_COMMON common( repo );
1644
1645 // Unset means merge, as git does.
1646 BOOST_CHECK( !common.IsPullRebaseConfigured() );
1647
1648 auto setRebase = [repo]( const char* aValue )
1649 {
1650 git_config* cfg = nullptr;
1651 BOOST_REQUIRE_EQUAL( git_repository_config( &cfg, repo ), GIT_OK );
1652 KIGIT::GitConfigPtr cfgPtr( cfg );
1653 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "pull.rebase", aValue ), GIT_OK );
1654 };
1655
1656 for( const char* yes : { "true", "yes", "on", "1" } )
1657 {
1658 setRebase( yes );
1659 BOOST_CHECK_MESSAGE( common.IsPullRebaseConfigured(), std::string( "pull.rebase=" ) + yes );
1660 }
1661
1662 for( const char* no : { "false", "no", "off", "0" } )
1663 {
1664 setRebase( no );
1665 BOOST_CHECK_MESSAGE( !common.IsPullRebaseConfigured(), std::string( "pull.rebase=" ) + no );
1666 }
1667}
1668
1669
1670// A detached HEAD is on no branch, so it has no upstream. That missing upstream must not read
1671// as unpushed work, while a branch without one still does.
1672BOOST_AUTO_TEST_CASE( DetachedHeadHasNoLocalCommits )
1673{
1674 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1675 KIGIT::GitRepositoryPtr repoPtr( repo );
1676
1677 KIGIT_COMMON common( repo );
1678 BOOST_REQUIRE( common.CreateTag( "v1.0" ) );
1679
1680 // On a branch with no upstream the commit really is unpublished.
1681 BOOST_REQUIRE( !common.IsHeadDetached() );
1682 BOOST_CHECK( common.HasLocalCommits() );
1683 BOOST_CHECK( !common.HasUpstreamCommits() );
1684
1685 BOOST_REQUIRE( common.CheckoutTag( "v1.0" ) );
1686 BOOST_REQUIRE( common.IsHeadDetached() );
1687 BOOST_CHECK( !common.HasLocalCommits() );
1688 BOOST_CHECK( !common.HasUpstreamCommits() );
1689
1690 std::optional<size_t> count = common.GetUnmergedCommitCount( "main" );
1691 BOOST_REQUIRE( count );
1692 BOOST_CHECK_EQUAL( *count, 0u );
1693}
1694
1695
1696// CheckoutTag puts the working tree at the tagged commit with HEAD detached.
1697BOOST_AUTO_TEST_CASE( CheckoutTagDetachesHead )
1698{
1699 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1700 KIGIT::GitRepositoryPtr repoPtr( repo );
1701
1702 KIGIT_COMMON common( repo );
1703 BOOST_REQUIRE( common.CreateTag( "v1.0" ) );
1704
1705 git_oid tagged;
1706 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &tagged, repo, "HEAD" ), GIT_OK );
1707
1708 // A later commit, so the checkout has to move both the tree and HEAD back.
1709 writeFile( TempPath(), "file.txt", "v2\n" );
1710 commitWorkingFile( repo, "file.txt", "second" );
1711
1712 BOOST_CHECK( !common.IsHeadDetached() );
1713 BOOST_CHECK( common.CheckoutTag( "v1.0" ) );
1714 BOOST_CHECK( common.IsHeadDetached() );
1715
1716 git_oid headNow;
1717 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &headNow, repo, "HEAD" ), GIT_OK );
1718 BOOST_CHECK( git_oid_equal( &headNow, &tagged ) );
1719 BOOST_CHECK_EQUAL( readWorkingFile( wxFileName( TempPath(), "file.txt" ) ), "v1\n" );
1720}
1721
1722
1723BOOST_AUTO_TEST_CASE( UnmergedCommitCountIsWhatHeadDoesNotContain )
1724{
1725 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1726 KIGIT::GitRepositoryPtr repoPtr( repo );
1727 KIGIT_COMMON common( repo );
1728
1729 BOOST_REQUIRE( common.CreateBranch( "feature" ) );
1730 std::optional<size_t> count = common.GetUnmergedCommitCount( "feature" );
1731 BOOST_REQUIRE( count );
1732 BOOST_CHECK_EQUAL( *count, 0u );
1733
1734 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo, "refs/heads/feature" ), GIT_OK );
1735
1736 wxFileName fn( TempPath(), wxS( "file.txt" ) );
1737 {
1738 std::ofstream f( fn.GetFullPath().utf8_string() );
1739 f << "v2\n";
1740 }
1741
1742 commitWorkingFile( repo, wxS( "file.txt" ), "only on feature" );
1743 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo, "refs/heads/main" ), GIT_OK );
1744
1745 count = common.GetUnmergedCommitCount( "feature" );
1746 BOOST_REQUIRE( count );
1747 BOOST_CHECK_EQUAL( *count, 1u );
1748 count = common.GetUnmergedCommitCount( "main" );
1749 BOOST_REQUIRE( count );
1750 BOOST_CHECK_EQUAL( *count, 0u );
1751 BOOST_CHECK( !common.GetUnmergedCommitCount( "no-such-branch" ) );
1752}
1753
1754
1755// Remotes round-trip without returning embedded user credentials.
1756BOOST_AUTO_TEST_CASE( RemoteRoundTripAndUserinfoStripped )
1757{
1758 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1759 KIGIT::GitRepositoryPtr repoPtr( repo );
1760
1761 KIGIT_COMMON common( repo );
1762 BOOST_CHECK( common.GetRemotes().empty() );
1763
1764 BOOST_CHECK( common.AddRemote( "origin", "https://example.com/x.git" ) );
1765
1766 std::vector<std::pair<wxString, wxString>> remotes = common.GetRemotes();
1767 BOOST_REQUIRE_EQUAL( remotes.size(), 1u );
1768 BOOST_CHECK_EQUAL( remotes[0].first.ToStdString(), "origin" );
1769 BOOST_CHECK_EQUAL( remotes[0].second.ToStdString(), "https://example.com/x.git" );
1770
1771 BOOST_CHECK( common.SetRemoteUrl( "origin", "https://u:[email protected]/y.git" ) );
1772
1773 // The password must not surface through GetRemotes.
1774 BOOST_CHECK_EQUAL( common.GetRemotes()[0].second.ToStdString(), "https://example.com/y.git" );
1775
1776 BOOST_CHECK( common.RemoveRemote( "origin" ) );
1777 BOOST_CHECK( common.GetRemotes().empty() );
1778}
1779
1780
1781// Branches created at the same HEAD share a timestamp. A std::map keyed on it would drop one.
1782BOOST_AUTO_TEST_CASE( GetBranchesKeepsBranchesSharingACommitTime )
1783{
1784 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1785 KIGIT::GitRepositoryPtr repoPtr( repo );
1786
1787 KIGIT_COMMON common( repo );
1788 BOOST_REQUIRE( common.CreateBranch( "alpha" ) );
1789 BOOST_REQUIRE( common.CreateBranch( "beta" ) );
1790
1791 std::vector<KIGIT_BRANCH_INFO> branches = common.GetBranches();
1792 BOOST_REQUIRE_EQUAL( branches.size(), 3u );
1793 std::set<wxString> names;
1794
1795 for( const KIGIT_BRANCH_INFO& branch : branches )
1796 {
1797 names.insert( branch.name );
1798 BOOST_CHECK( !branch.isRemote );
1799 BOOST_CHECK( branch.when == branches.front().when );
1800 }
1801
1802 BOOST_CHECK_EQUAL( names.count( wxS( "main" ) ), 1u );
1803 BOOST_CHECK_EQUAL( names.count( wxS( "alpha" ) ), 1u );
1804 BOOST_CHECK_EQUAL( names.count( wxS( "beta" ) ), 1u );
1805}
1806
1807
1808// Branch listings carry the tip commit and identify the current branch.
1809BOOST_AUTO_TEST_CASE( GetBranchesCarriesTipAndCurrent )
1810{
1811 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1812 KIGIT::GitRepositoryPtr repoPtr( repo );
1813
1814 KIGIT_COMMON common( repo );
1815 BOOST_REQUIRE( common.CreateBranch( "older" ) );
1816
1817 writeFile( TempPath(), "file.txt", "v2\n" );
1818 const char* message = "second\n\nA full commit message.\n";
1819 commitWorkingFile( repo, "file.txt", message );
1820
1821 std::vector<KIGIT_COMMIT_INFO> commits = common.GetRecentCommits( 10 );
1822 BOOST_REQUIRE_EQUAL( commits.size(), 2u );
1823
1824 auto hashOf = [&]( const char* aSummary )
1825 {
1826 for( const KIGIT_COMMIT_INFO& c : commits )
1827 {
1828 if( c.summary == aSummary )
1829 return c.hash.ToStdString();
1830 }
1831
1832 return std::string();
1833 };
1834
1835 std::vector<KIGIT_BRANCH_INFO> branches = common.GetBranches();
1836 BOOST_REQUIRE_EQUAL( branches.size(), 2u );
1837
1838 for( const KIGIT_BRANCH_INFO& branch : branches )
1839 {
1840 BOOST_CHECK( !branch.isRemote );
1841 BOOST_CHECK( branch.when.IsValid() );
1842
1843 if( branch.name == wxS( "main" ) )
1844 {
1845 BOOST_CHECK( branch.isCurrent );
1846 BOOST_CHECK_EQUAL( branch.tipHash.ToStdString(), hashOf( "second" ) );
1847 BOOST_CHECK_EQUAL( branch.message.ToStdString(), message );
1848 }
1849 else
1850 {
1851 BOOST_CHECK_EQUAL( branch.name.ToStdString(), "older" );
1852 BOOST_CHECK( !branch.isCurrent );
1853 BOOST_CHECK_EQUAL( branch.tipHash.ToStdString(), hashOf( "initial" ) );
1854 BOOST_CHECK_EQUAL( branch.message.ToStdString(), "initial" );
1855 }
1856 }
1857}
1858
1859
1860BOOST_AUTO_TEST_CASE( GetBranchesDistinguishesRemoteNamesAndSkipsSymbolicAliases )
1861{
1862 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "v1\n" ) );
1863 KIGIT_COMMON common( repo.get() );
1864 BOOST_REQUIRE( common.CreateBranch( "origin/topic" ) );
1865
1866 git_oid headOid;
1867 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &headOid, repo.get(), "HEAD" ), GIT_OK );
1868 git_reference* remote = nullptr;
1869 BOOST_REQUIRE_EQUAL( git_reference_create( &remote, repo.get(), "refs/remotes/origin/topic", &headOid, 0, nullptr ),
1870 GIT_OK );
1871 KIGIT::GitReferencePtr remotePtr( remote );
1872
1873 git_reference* alias = nullptr;
1874 BOOST_REQUIRE_EQUAL( git_reference_symbolic_create( &alias, repo.get(), "refs/remotes/origin/HEAD",
1875 "refs/remotes/origin/topic", 0, nullptr ),
1876 GIT_OK );
1877 KIGIT::GitReferencePtr aliasPtr( alias );
1878
1879 std::vector<KIGIT_BRANCH_INFO> branches = common.GetBranches();
1880 BOOST_REQUIRE_EQUAL( branches.size(), 3u );
1881 unsigned int localCount = 0;
1882 unsigned int remoteCount = 0;
1883
1884 for( const KIGIT_BRANCH_INFO& branch : branches )
1885 {
1886 if( branch.name == wxS( "origin/topic" ) )
1887 {
1888 if( branch.isRemote )
1889 ++remoteCount;
1890 else
1891 ++localCount;
1892
1893 BOOST_CHECK( !branch.isCurrent );
1894 }
1895 else
1896 {
1897 BOOST_CHECK_EQUAL( branch.name.ToStdString(), "main" );
1898 BOOST_CHECK( branch.isCurrent );
1899 BOOST_CHECK( !branch.isRemote );
1900 }
1901 }
1902
1903 BOOST_CHECK_EQUAL( localCount, 1u );
1904 BOOST_CHECK_EQUAL( remoteCount, 1u );
1905
1906 BOOST_REQUIRE_EQUAL( git_repository_set_head_detached( repo.get(), &headOid ), GIT_OK );
1907 branches = common.GetBranches();
1908 BOOST_REQUIRE_EQUAL( branches.size(), 3u );
1909 BOOST_CHECK( std::none_of( branches.begin(), branches.end(),
1910 []( const KIGIT_BRANCH_INFO& aBranch )
1911 {
1912 return aBranch.isCurrent;
1913 } ) );
1914}
1915
1916
1917BOOST_AUTO_TEST_CASE( SortBranchesForDisplayGroupsBranchesAndBreaksTimeTies )
1918{
1919 const wxDateTime older( 1, wxDateTime::Jan, 2020 );
1920 const wxDateTime newer( 2, wxDateTime::Jan, 2020 );
1921
1922 auto branch = []( const wxString& aName, const wxDateTime& aWhen, bool aRemote = false, bool aCurrent = false )
1923 {
1925 info.name = aName;
1926 info.when = aWhen;
1927 info.isRemote = aRemote;
1928 info.isCurrent = aCurrent;
1929 return info;
1930 };
1931
1932 std::vector<KIGIT_BRANCH_INFO> branches = {
1933 branch( "origin/unknown", wxDateTime(), true ), branch( "b-tie", older ),
1934 branch( "origin/z-new", newer, true ), branch( "a-undated", wxDateTime() ),
1935 branch( "origin/a-old", older, true ), branch( "z-new", newer ),
1936 branch( "main", wxDateTime(), false, true ), branch( "a-tie", older )
1937 };
1938
1940
1941 const std::vector<wxString> expected = { "main", "z-new", "a-tie", "b-tie",
1942 "a-undated", "origin/z-new", "origin/a-old", "origin/unknown" };
1943
1944 BOOST_REQUIRE_EQUAL( branches.size(), expected.size() );
1945
1946 for( size_t i = 0; i < expected.size(); ++i )
1947 BOOST_CHECK_EQUAL( branches[i].name.ToStdString(), expected[i].ToStdString() );
1948}
1949
1950
1951// The detail pane shows a branch's tip commit and how far it is from the branch it tracks.
1952BOOST_AUTO_TEST_CASE( GetBranchDetailReportsTipAndUpstream )
1953{
1954 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
1955 KIGIT::GitRepositoryPtr repoPtr( repo );
1956
1957 KIGIT_COMMON common( repo );
1958 BOOST_REQUIRE( common.CreateBranch( "base" ) );
1959
1960 writeFile( TempPath(), "file.txt", "v2\n" );
1961 commitWorkingFile( repo, "file.txt", "second\n\nWith a body." );
1962
1963 // Track the local branch, which git allows, so no remote is needed to see ahead and behind.
1964 git_reference* main = nullptr;
1965 BOOST_REQUIRE_EQUAL( git_branch_lookup( &main, repo, "main", GIT_BRANCH_LOCAL ), GIT_OK );
1966 KIGIT::GitReferencePtr mainPtr( main );
1967 BOOST_REQUIRE_EQUAL( git_branch_set_upstream( main, "base" ), GIT_OK );
1968
1969 KIGIT_BRANCH_DETAIL detail = common.GetBranchDetail( wxS( "main" ) );
1970 BOOST_REQUIRE( detail.found );
1971 BOOST_CHECK_EQUAL( detail.name.ToStdString(), "main" );
1972 BOOST_CHECK_EQUAL( detail.tipHash.ToStdString(), common.GetRecentCommits( 1 )[0].hash.ToStdString() );
1973 BOOST_CHECK_EQUAL( detail.author.ToStdString(), "QA Test" );
1974 BOOST_CHECK_EQUAL( detail.email.ToStdString(), "[email protected]" );
1975 BOOST_CHECK_EQUAL( detail.message.ToStdString(), "second\n\nWith a body." );
1976 BOOST_CHECK_EQUAL( detail.upstream.ToStdString(), "base" );
1977 BOOST_REQUIRE( detail.divergence );
1978 BOOST_CHECK_EQUAL( detail.divergence->ahead, 1u );
1979 BOOST_CHECK_EQUAL( detail.divergence->behind, 0u );
1980
1981 KIGIT_BRANCH_DETAIL base = common.GetBranchDetail( wxS( "base" ) );
1982 BOOST_REQUIRE( base.found );
1983 BOOST_CHECK( base.upstream.IsEmpty() );
1984 BOOST_CHECK( !base.divergence );
1985 BOOST_CHECK_EQUAL( base.message.ToStdString(), "initial" );
1986
1987 BOOST_CHECK( !common.GetBranchDetail( wxS( "nowhere" ) ).found );
1988}
1989
1990
1991BOOST_AUTO_TEST_CASE( BranchComparisonsUseTheSelectedBranchAndItsOwnUpstream )
1992{
1993 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "initial\n" ) );
1994 KIGIT_COMMON common( repo.get() );
1995 BOOST_REQUIRE( common.CreateBranch( "selected" ) );
1996 BOOST_REQUIRE( common.CreateBranch( "main-upstream" ) );
1997
1998 auto commitOn = [&]( const char* aBranch, const char* aMessage )
1999 {
2000 wxString ref = wxS( "refs/heads/" ) + wxString::FromUTF8( aBranch );
2001 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo.get(), ref.utf8_str() ), GIT_OK );
2002 writeFile( TempPath(), "file.txt", aMessage );
2003 commitWorkingFile( repo.get(), "file.txt", aMessage );
2004 };
2005
2006 commitOn( "selected", "selected one" );
2007 BOOST_REQUIRE( common.CreateBranch( "selected-upstream" ) );
2008 commitOn( "selected", "selected two" );
2009 commitOn( "selected-upstream", "selected upstream one" );
2010 commitOn( "main-upstream", "main upstream one" );
2011 commitOn( "main-upstream", "main upstream two" );
2012 commitOn( "main-upstream", "main upstream three" );
2013 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo.get(), "refs/heads/main" ), GIT_OK );
2014
2015 auto setUpstream = [&]( const char* aBranch, const char* aUpstream )
2016 {
2017 git_reference* ref = nullptr;
2018 BOOST_REQUIRE_EQUAL( git_branch_lookup( &ref, repo.get(), aBranch, GIT_BRANCH_LOCAL ), GIT_OK );
2019 KIGIT::GitReferencePtr refPtr( ref );
2020 BOOST_REQUIRE_EQUAL( git_branch_set_upstream( ref, aUpstream ), GIT_OK );
2021 };
2022
2023 setUpstream( "main", "main-upstream" );
2024 setUpstream( "selected", "selected-upstream" );
2025
2026 // The selected branch and its upstream each have one new commit.
2027 KIGIT_BRANCH_DETAIL selected = common.GetBranchDetail( "selected" );
2028 BOOST_REQUIRE( selected.found );
2029 BOOST_CHECK_EQUAL( selected.upstream.ToStdString(), "selected-upstream" );
2030 BOOST_REQUIRE( selected.divergence );
2031 BOOST_CHECK_EQUAL( selected.divergence->ahead, 1u );
2032 BOOST_CHECK_EQUAL( selected.divergence->behind, 1u );
2033
2034 // Both selected branch commits are missing from HEAD.
2035 std::optional<size_t> count = common.GetUnmergedCommitCount( "selected" );
2036 BOOST_REQUIRE( count );
2037 BOOST_CHECK_EQUAL( *count, 2u );
2038
2039 KIGIT_BRANCH_DETAIL main = common.GetBranchDetail( "main" );
2040 BOOST_REQUIRE( main.divergence );
2041 BOOST_CHECK_EQUAL( main.divergence->ahead, 0u );
2042 BOOST_CHECK_EQUAL( main.divergence->behind, 3u );
2043 BOOST_CHECK( !common.HasLocalCommits() );
2044 BOOST_CHECK( common.HasUpstreamCommits() );
2045
2046 commitOn( "main", "local main one" );
2047 main = common.GetBranchDetail( "main" );
2048 BOOST_REQUIRE( main.divergence );
2049 BOOST_CHECK_EQUAL( main.divergence->ahead, 1u );
2050 BOOST_CHECK_EQUAL( main.divergence->behind, 3u );
2051 BOOST_CHECK( common.HasLocalCommits() );
2052 BOOST_CHECK( common.HasUpstreamCommits() );
2053}
2054
2055
2056BOOST_AUTO_TEST_CASE( BranchComparisonsStayUnknownWhenReferencesCannotBeRead )
2057{
2058 KIGIT_COMMON unavailable( nullptr );
2059 BOOST_CHECK( !unavailable.HasLocalCommits() );
2060 BOOST_CHECK( !unavailable.HasUpstreamCommits() );
2061 BOOST_CHECK( !unavailable.GetUnmergedCommitCount( "main" ) );
2062 BOOST_CHECK( !unavailable.GetBranchDetail( "main" ).divergence );
2063
2064 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "initial\n" ) );
2065 KIGIT_COMMON common( repo.get() );
2066
2067 // The reference exists, but its commit is missing.
2068 writeFile( TempPath(), ".git/refs/heads/broken", "1111111111111111111111111111111111111111\n" );
2069 git_config* config = nullptr;
2070 BOOST_REQUIRE_EQUAL( git_repository_config( &config, repo.get() ), GIT_OK );
2071 KIGIT::GitConfigPtr configPtr( config );
2072 BOOST_REQUIRE_EQUAL( git_config_set_string( config, "branch.main.remote", "." ), GIT_OK );
2073 BOOST_REQUIRE_EQUAL( git_config_set_string( config, "branch.main.merge", "refs/heads/broken" ), GIT_OK );
2074
2075 KIGIT_BRANCH_DETAIL detail = common.GetBranchDetail( "main" );
2076 BOOST_REQUIRE( detail.found );
2077 BOOST_CHECK_EQUAL( detail.upstream.ToStdString(), "broken" );
2078 BOOST_CHECK( !detail.divergence );
2079 BOOST_CHECK( !common.HasLocalCommits() );
2080 BOOST_CHECK( !common.HasUpstreamCommits() );
2081 BOOST_CHECK( !common.GetUnmergedCommitCount( "broken" ) );
2082}
2083
2084
2085// New Branch in the detail pane starts the branch where the selected one is, not at HEAD.
2086BOOST_AUTO_TEST_CASE( CreateBranchStartsAtTheGivenBranch )
2087{
2088 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2089 KIGIT::GitRepositoryPtr repoPtr( repo );
2090
2091 KIGIT_COMMON common( repo );
2092 BOOST_REQUIRE( common.CreateBranch( "base" ) );
2093
2094 writeFile( TempPath(), "file.txt", "v2\n" );
2095 commitWorkingFile( repo, "file.txt", "second" );
2096
2097 BOOST_REQUIRE( common.CreateBranch( "feature", "base" ) );
2098
2099 KIGIT_BRANCH_DETAIL feature = common.GetBranchDetail( wxS( "feature" ) );
2100 KIGIT_BRANCH_DETAIL base = common.GetBranchDetail( wxS( "base" ) );
2101 BOOST_REQUIRE( feature.found && base.found );
2102 BOOST_CHECK_EQUAL( feature.tipHash.ToStdString(), base.tipHash.ToStdString() );
2103
2104 BOOST_CHECK( !common.CreateBranch( "orphan", "nowhere" ) );
2105}
2106
2107
2108// The Remotes page shows when the repository last fetched. Git leaves that in FETCH_HEAD, which
2109// a repository that never fetched does not have.
2110BOOST_AUTO_TEST_CASE( GetLastFetchTimeReadsFetchHead )
2111{
2112 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2113 KIGIT::GitRepositoryPtr repoPtr( repo );
2114
2115 KIGIT_COMMON common( repo );
2116 BOOST_CHECK( !common.GetLastFetchTime().IsValid() );
2117
2118 wxFileName fetchHead( wxString::FromUTF8( git_repository_path( repo ) ), wxS( "FETCH_HEAD" ) );
2119
2120 {
2121 std::ofstream f( fetchHead.GetFullPath().utf8_string() );
2122 f << "0000000000000000000000000000000000000000\t\tbranch 'main' of https://example.com/x.git\n";
2123 }
2124
2125 // The time is the file's own, so set it to something that is not now.
2126 wxDateTime stamp( 1, wxDateTime::Jan, 2020, 12, 0, 0 );
2127 BOOST_REQUIRE( fetchHead.SetTimes( nullptr, &stamp, nullptr ) );
2128
2129 wxDateTime when = common.GetLastFetchTime();
2130 BOOST_REQUIRE( when.IsValid() );
2131 BOOST_CHECK( when.IsEqualTo( stamp ) );
2132}
2133
2134
2135// The Changes page sums the lines the working tree adds and removes, a new file counting as
2136// all added.
2137BOOST_AUTO_TEST_CASE( GetWorkingTreeLineStatsCountsEditsAndNewFiles )
2138{
2139 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "one\ntwo\nthree\n" );
2140 KIGIT::GitRepositoryPtr repoPtr( repo );
2141
2142 KIGIT_COMMON common( repo );
2143
2144 std::pair<int, int> clean = common.GetWorkingTreeLineStats( { wxS( "file.txt" ) } );
2145 BOOST_CHECK_EQUAL( clean.first, 0 );
2146 BOOST_CHECK_EQUAL( clean.second, 0 );
2147
2148 writeFile( TempPath(), "file.txt", "one\n2\nthree\n" );
2149 writeFile( TempPath(), "new.txt", "a\nb\n" );
2150
2151 std::pair<int, int> dirty = common.GetWorkingTreeLineStats( { wxS( "file.txt" ), wxS( "new.txt" ) } );
2152 BOOST_CHECK_EQUAL( dirty.first, 3 );
2153 BOOST_CHECK_EQUAL( dirty.second, 1 );
2154}
2155
2156
2157// The detail pane shows what a stash holds: the branch and commit it was taken on, its message
2158// and the files it changes, a new file included.
2159BOOST_AUTO_TEST_CASE( GetStashDetailNamesBranchCommitAndFiles )
2160{
2161 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2162 KIGIT::GitRepositoryPtr repoPtr( repo );
2163
2164 KIGIT_COMMON common( repo );
2165
2166 std::vector<KIGIT_COMMIT_INFO> commits = common.GetRecentCommits( 1 );
2167 BOOST_REQUIRE_EQUAL( commits.size(), 1u );
2168
2169 writeFile( TempPath(), "file.txt", "v2\n" );
2170 writeFile( TempPath(), "new.txt", "n\n" );
2171
2172 {
2173 git_index* index = nullptr;
2174 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
2175 KIGIT::GitIndexPtr indexPtr( index );
2176 BOOST_REQUIRE_EQUAL( git_index_add_bypath( index, "new.txt" ), GIT_OK );
2177 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
2178 }
2179
2180 BOOST_REQUIRE( common.StashSave( wxS( "connector work" ) ) );
2181
2182 std::vector<KIGIT_STASH_INFO> stashes = common.GetStashes();
2183 BOOST_REQUIRE_EQUAL( stashes.size(), 1u );
2184
2185 KIGIT_STASH_DETAIL detail = common.GetStashDetail( stashes[0].id );
2186 BOOST_REQUIRE( detail.found );
2187 BOOST_CHECK_EQUAL( detail.message.ToStdString(), "connector work" );
2188 BOOST_CHECK_EQUAL( detail.branch.ToStdString(), "main" );
2189 BOOST_CHECK_EQUAL( detail.baseHash.ToStdString(), commits[0].hash.ToStdString() );
2190 BOOST_CHECK( detail.when.IsValid() );
2191
2192 std::vector<std::string> files;
2193
2194 for( const wxString& f : detail.files )
2195 files.push_back( f.ToStdString() );
2196
2197 std::sort( files.begin(), files.end() );
2198 BOOST_REQUIRE_EQUAL( files.size(), 2u );
2199 BOOST_CHECK_EQUAL( files[0], "file.txt" );
2200 BOOST_CHECK_EQUAL( files[1], "new.txt" );
2201
2202 BOOST_CHECK( !common.GetStashDetail( wxS( "0000000000000000000000000000000000000000" ) ).found );
2203}
2204
2205
2206// The History page can be narrowed to one file or folder. A commit stays listed when the tree
2207// entry at that path differs from its parent's, so a folder catches changes anywhere under it.
2208BOOST_AUTO_TEST_CASE( GetRecentCommitsFiltersByPath )
2209{
2210 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2211 KIGIT::GitRepositoryPtr repoPtr( repo );
2212
2213 wxFileName::Mkdir( TempPath() + wxS( "/sub" ) );
2214 writeFile( TempPath(), "sub/a.txt", "a\n" );
2215 commitWorkingFile( repo, "sub/a.txt", "add a" );
2216 writeFile( TempPath(), "file.txt", "v2\n" );
2217 commitWorkingFile( repo, "file.txt", "edit file" );
2218 writeFile( TempPath(), "sub/b.txt", "b\n" );
2219 commitWorkingFile( repo, "sub/b.txt", "add b" );
2220
2221 KIGIT_COMMON common( repo );
2223
2224 filter.path = wxS( "file.txt" );
2225 std::vector<KIGIT_COMMIT_INFO> commits = common.GetRecentCommits( 50, filter );
2226 BOOST_REQUIRE_EQUAL( commits.size(), 2 );
2227 BOOST_CHECK_EQUAL( commits[0].summary, wxS( "edit file" ) );
2228 BOOST_CHECK_EQUAL( commits[1].summary, wxS( "initial" ) );
2229
2230 filter.path = wxS( "sub" );
2231 commits = common.GetRecentCommits( 50, filter );
2232 BOOST_REQUIRE_EQUAL( commits.size(), 2 );
2233 BOOST_CHECK_EQUAL( commits[0].summary, wxS( "add b" ) );
2234 BOOST_CHECK_EQUAL( commits[1].summary, wxS( "add a" ) );
2235
2236 filter.path = wxS( "sub/a.txt" );
2237 BOOST_CHECK_EQUAL( common.GetRecentCommits( 50, filter ).size(), 1 );
2238
2239 filter.path = wxS( "nowhere.txt" );
2240 BOOST_CHECK( common.GetRecentCommits( 50, filter ).empty() );
2241
2242 // The search still applies on top of the path.
2243 filter.path = wxS( "sub" );
2244 filter.search = wxS( "add b" );
2245 BOOST_CHECK_EQUAL( common.GetRecentCommits( 50, filter ).size(), 1 );
2246}
2247
2248
2249// The Tags page shows a tag's message, author and date. An annotated tag has its own, a
2250// lightweight tag borrows the commit's.
2251// libgit2 runs no filter program, so a file git routes through LFS has to be recognised before
2252// an operation writes the placeholder text over it.
2253BOOST_AUTO_TEST_CASE( ProjectUsesGitLfsFindsAFilteredFile )
2254{
2256
2257
2258 git_repository* repo = makeRepoWithCommit( TempPath(), "board.kicad_pcb", "(kicad_pcb)\n" );
2259 KIGIT::GitRepositoryPtr repoPtr( repo );
2260 KIGIT_COMMON common( repo, TempPath() );
2261
2262 BOOST_CHECK( !PROJECT_GIT_UTILS::ProjectUsesGitLfs( common ) );
2263
2264 writeFile( TempPath(), ".gitattributes", "*.step filter=lfs diff=lfs merge=lfs -text\n" );
2265 commitWorkingFile( repo, ".gitattributes", "route step files through lfs" );
2266 writeFile( TempPath(), "case.step", "ISO-10303-21;\n" );
2267 commitWorkingFile( repo, "case.step", "add the enclosure" );
2268
2269 BOOST_CHECK( PROJECT_GIT_UTILS::PathUsesGitLfs( repo, wxS( "case.step" ) ) );
2270 BOOST_CHECK( !PROJECT_GIT_UTILS::PathUsesGitLfs( repo, wxS( "board.kicad_pcb" ) ) );
2271
2272 wxString example;
2273 BOOST_CHECK( PROJECT_GIT_UTILS::ProjectUsesGitLfs( common, &example ) );
2274 BOOST_CHECK_EQUAL( example.ToStdString(), "case.step" );
2275
2276 // A project folder that holds none of the filtered files is left alone.
2277 const wxString elsewhere = TempPath() + wxS( "/elsewhere" );
2278 BOOST_REQUIRE( wxFileName::Mkdir( elsewhere ) );
2279 KIGIT_COMMON otherProject( repo, elsewhere );
2280 BOOST_CHECK( !PROJECT_GIT_UTILS::ProjectUsesGitLfs( otherProject ) );
2281
2282 std::optional<wxString> prefix = PROJECT_GIT_UTILS::ProjectPrefix( wxS( "/repo" ), wxS( "/repo/proj" ) );
2283 BOOST_REQUIRE( prefix );
2284 BOOST_CHECK_EQUAL( prefix->ToStdString(), "proj/" );
2285 prefix = PROJECT_GIT_UTILS::ProjectPrefix( wxS( "/repo" ), wxS( "/repo" ) );
2286 BOOST_REQUIRE( prefix );
2287 BOOST_CHECK( prefix->IsEmpty() );
2288 BOOST_CHECK( !PROJECT_GIT_UTILS::ProjectPrefix( wxS( "/repo" ), wxS( "/other/proj" ) ) );
2289 BOOST_CHECK( !PROJECT_GIT_UTILS::ProjectPrefix( wxS( "/repo" ), wxS( "/repo2/proj" ) ) );
2290 BOOST_CHECK( !PROJECT_GIT_UTILS::ProjectPrefix( wxS( "/repo" ), wxEmptyString ) );
2291
2292#ifdef __WINDOWS__
2293 // The working directory comes from libgit2 and the project path from KiCad, so the two
2294 // can disagree about separators and about case. Windows does not care about either.
2295 prefix = PROJECT_GIT_UTILS::ProjectPrefix( wxS( "C:\\repo" ), wxS( "C:/repo/proj" ) );
2296 BOOST_REQUIRE( prefix );
2297 BOOST_CHECK_EQUAL( prefix->ToStdString(), "proj/" );
2298 prefix = PROJECT_GIT_UTILS::ProjectPrefix( wxS( "c:\\Repo\\" ), wxS( "C:\\repo\\Proj" ) );
2299 BOOST_REQUIRE( prefix );
2300 BOOST_CHECK_EQUAL( prefix->ToStdString(), "Proj/" );
2301 BOOST_CHECK( !PROJECT_GIT_UTILS::ProjectPrefix( wxS( "C:\\repo" ), wxS( "D:\\repo\\proj" ) ) );
2302#endif
2303}
2304
2305
2306BOOST_AUTO_TEST_CASE( ProjectUsesGitLfsStaysWithinTheSessionProject )
2307{
2309
2311 writeFile( TempPath(), ".gitattributes", "*.step filter=lfs diff=lfs merge=lfs -text\n" );
2312 commitWorkingFile( repo.get(), ".gitattributes", "use lfs" );
2313 writeFile( TempPath(), "projects/board/board.kicad_pcb", "(kicad_pcb)\n" );
2314 commitWorkingFile( repo.get(), "projects/board/board.kicad_pcb", "add project" );
2315 writeFile( TempPath(), "projects/board2/case.step", "sibling enclosure\n" );
2316 commitWorkingFile( repo.get(), "projects/board2/case.step", "add sibling enclosure" );
2317
2318 KIGIT_COMMON common( repo.get(), TempPath() + wxS( "/projects/board" ) );
2319 wxString example;
2320 BOOST_CHECK( !PROJECT_GIT_UTILS::ProjectUsesGitLfs( common, &example ) );
2321 BOOST_CHECK( PROJECT_GIT_UTILS::LfsRefusal( &common ).IsEmpty() );
2322
2323 KIGIT_COMMON unknownProject( repo.get() );
2324 BOOST_CHECK( !PROJECT_GIT_UTILS::LfsRefusal( &unknownProject ).IsEmpty() );
2325
2326 writeFile( TempPath(), "projects/board/models/case.step", "project enclosure\n" );
2327 commitWorkingFile( repo.get(), "projects/board/models/case.step", "add project enclosure" );
2328
2329 BOOST_CHECK( PROJECT_GIT_UTILS::ProjectUsesGitLfs( common, &example ) );
2330 BOOST_CHECK_EQUAL( example.ToStdString(), "projects/board/models/case.step" );
2331 BOOST_CHECK( PROJECT_GIT_UTILS::LfsRefusal( &common ).Contains( example ) );
2332}
2333
2334
2335// An amend or a rebase leaves a commit's author time behind its committer time. The History
2336// page dates a commit by the committer time, so the Tags page has to agree.
2337BOOST_AUTO_TEST_CASE( GetTagDetailDatesALightweightTagByCommitterTime )
2338{
2339 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2340 KIGIT::GitRepositoryPtr repoPtr( repo );
2341
2342 git_signature* authored = nullptr;
2343 BOOST_REQUIRE_EQUAL( git_signature_new( &authored, "QA Test", "[email protected]", 1000, 0 ), GIT_OK );
2344 KIGIT::GitSignaturePtr authoredPtr( authored );
2345
2346 git_signature* committed = nullptr;
2347 BOOST_REQUIRE_EQUAL( git_signature_new( &committed, "QA Test", "[email protected]", 2000, 0 ), GIT_OK );
2348 KIGIT::GitSignaturePtr committedPtr( committed );
2349
2350 git_oid headOid;
2351 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &headOid, repo, "HEAD" ), GIT_OK );
2352
2353 git_commit* parent = nullptr;
2354 BOOST_REQUIRE_EQUAL( git_commit_lookup( &parent, repo, &headOid ), GIT_OK );
2355 KIGIT::GitCommitPtr parentPtr( parent );
2356
2357 git_tree* tree = nullptr;
2358 BOOST_REQUIRE_EQUAL( git_commit_tree( &tree, parent ), GIT_OK );
2359 KIGIT::GitTreePtr tree_ptr( tree );
2360
2361 const git_commit* parents[1] = { parent };
2362 git_oid commitOid;
2363 BOOST_REQUIRE_EQUAL(
2364 git_commit_create( &commitOid, repo, "HEAD", authored, committed, nullptr, "rebased", tree, 1, parents ),
2365 GIT_OK );
2366
2367 git_object* target = nullptr;
2368 BOOST_REQUIRE_EQUAL( git_revparse_single( &target, repo, "HEAD" ), GIT_OK );
2369 KIGIT::GitObjectPtr targetPtr( target );
2370
2371 git_oid tagOid;
2372 BOOST_REQUIRE_EQUAL( git_tag_create_lightweight( &tagOid, repo, "rebased", target, 0 ), GIT_OK );
2373
2374 KIGIT_COMMON common( repo );
2375 KIGIT_TAG_DETAIL detail = common.GetTagDetail( wxS( "rebased" ) );
2376
2377 BOOST_REQUIRE( detail.found );
2378 BOOST_CHECK_EQUAL( detail.when.GetTicks(), 2000 );
2379 BOOST_CHECK_EQUAL( common.GetRecentCommits( 1 )[0].when.GetTicks(), detail.when.GetTicks() );
2380}
2381
2382
2383BOOST_AUTO_TEST_CASE( GetTagDetailReadsAnnotatedAndLightweightTags )
2384{
2385 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2386 KIGIT::GitRepositoryPtr repoPtr( repo );
2387
2388 KIGIT_COMMON common( repo );
2389 wxString head = common.GetRecentCommits( 1 )[0].hash;
2390
2391 // A different tagger from the commit author, so the detail must come from the tag object.
2392 BOOST_REQUIRE( common.SetUserConfig( wxS( "Tag Maker" ), wxS( "[email protected]" ) ) );
2393 BOOST_REQUIRE( common.CreateTag( "v1.0", "Release v1.0\n\n- Add mounting holes" ) );
2394 BOOST_REQUIRE( common.CreateTag( "beta-1" ) );
2395
2396 git_object* tagObject = nullptr;
2397 BOOST_REQUIRE_EQUAL( git_revparse_single( &tagObject, repo, "refs/tags/v1.0" ), GIT_OK );
2398 KIGIT::GitObjectPtr tagObjectPtr( tagObject );
2399 BOOST_CHECK_EQUAL( git_object_type( tagObject ), GIT_OBJECT_TAG );
2400
2401 KIGIT_TAG_DETAIL annotated = common.GetTagDetail( wxS( "v1.0" ) );
2402 BOOST_REQUIRE( annotated.found );
2403 BOOST_CHECK_EQUAL( annotated.name.ToStdString(), "v1.0" );
2404 BOOST_CHECK_EQUAL( annotated.hash.ToStdString(), head.ToStdString() );
2405 BOOST_CHECK_EQUAL( annotated.message.ToStdString(), "Release v1.0\n\n- Add mounting holes" );
2406 BOOST_CHECK_EQUAL( annotated.author.ToStdString(), "Tag Maker" );
2407 BOOST_CHECK_EQUAL( annotated.email.ToStdString(), "[email protected]" );
2408 BOOST_CHECK( annotated.when.IsValid() );
2409
2410 KIGIT_TAG_DETAIL lightweight = common.GetTagDetail( wxS( "beta-1" ) );
2411 BOOST_REQUIRE( lightweight.found );
2412 BOOST_CHECK_EQUAL( lightweight.name.ToStdString(), "beta-1" );
2413 BOOST_CHECK_EQUAL( lightweight.hash.ToStdString(), head.ToStdString() );
2414 BOOST_CHECK_EQUAL( lightweight.message.ToStdString(), "initial" );
2415 BOOST_CHECK_EQUAL( lightweight.author.ToStdString(), "QA Test" );
2416 BOOST_CHECK( lightweight.when.IsValid() );
2417
2418 BOOST_CHECK( !common.GetTagDetail( wxS( "nowhere" ) ).found );
2419}
2420
2421
2422// The Tags page lists newest first. A tag on a tree has no commit and so no date, and goes last.
2423BOOST_AUTO_TEST_CASE( GetTagsListsNewestFirstAndKeepsTagsWithoutCommits )
2424{
2425 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2426 KIGIT::GitRepositoryPtr repoPtr( repo );
2427
2428 KIGIT_COMMON common( repo );
2429 BOOST_REQUIRE( common.CreateTag( "v1.0", "Release" ) );
2430 BOOST_REQUIRE( common.CreateTag( "beta-1" ) );
2431
2432 // An old tag made long ago, and a tag on a tree.
2433 git_object* commitObject = nullptr;
2434 BOOST_REQUIRE_EQUAL( git_revparse_single( &commitObject, repo, "HEAD" ), GIT_OK );
2435 KIGIT::GitObjectPtr commitObjectPtr( commitObject );
2436
2437 git_signature* oldSig = nullptr;
2438 BOOST_REQUIRE_EQUAL( git_signature_new( &oldSig, "QA Test", "[email protected]", 1000, 0 ), GIT_OK );
2439 KIGIT::GitSignaturePtr oldSigPtr( oldSig );
2440
2441 git_oid oid;
2442 BOOST_REQUIRE_EQUAL( git_tag_create( &oid, repo, "old", commitObject, oldSig, "old tag", 0 ), GIT_OK );
2443
2444 git_object* treeObject = nullptr;
2445 BOOST_REQUIRE_EQUAL( git_revparse_single( &treeObject, repo, "HEAD^{tree}" ), GIT_OK );
2446 KIGIT::GitObjectPtr treeObjectPtr( treeObject );
2447 BOOST_REQUIRE_EQUAL( git_tag_create_lightweight( &oid, repo, "tree-tag", treeObject, 0 ), GIT_OK );
2448
2449 // The two tags made just now share a second, so their order between them is not fixed.
2450 std::vector<KIGIT_TAG_INFO> tags = common.GetTags();
2451 BOOST_REQUIRE_EQUAL( tags.size(), 4 );
2452 std::set<wxString> newest{ tags[0].name, tags[1].name };
2453 std::set<wxString> madeNow{ wxS( "v1.0" ), wxS( "beta-1" ) };
2454 BOOST_CHECK( newest == madeNow );
2455 BOOST_CHECK_EQUAL( tags[2].name.ToStdString(), "old" );
2456 BOOST_CHECK_EQUAL( tags[3].name.ToStdString(), "tree-tag" );
2457 BOOST_CHECK( tags[3].hash.IsEmpty() && !tags[3].when.IsValid() );
2458}
2459
2460
2461// The docked panel's Recent messages dropdown offers earlier messages to start from. Newest
2462// first, a message that was used twice listed once, and only what HEAD can reach.
2463BOOST_AUTO_TEST_CASE( GetRecentCommitMessagesListsDistinctMessagesNewestFirst )
2464{
2465 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2466 KIGIT::GitRepositoryPtr repoPtr( repo );
2467
2468 writeFile( TempPath(), "file.txt", "v2\n" );
2469 commitWorkingFile( repo, "file.txt", "Update BOM\n\nSecond pass.\n" );
2470 writeFile( TempPath(), "file.txt", "v3\n" );
2471 commitWorkingFile( repo, "file.txt", "Route power\n" );
2472 writeFile( TempPath(), "file.txt", "v4\n" );
2473 commitWorkingFile( repo, "file.txt", "Update BOM\n\nSecond pass.\n" );
2474 writeFile( TempPath(), "file.txt", "v5\n" );
2475 commitWorkingFile( repo, "file.txt", " \n\n" );
2476
2477 KIGIT_COMMON common( repo );
2478 std::vector<wxString> messages = common.GetRecentCommitMessages( 10 );
2479
2480 // The blank message is not offered.
2481 BOOST_REQUIRE_EQUAL( messages.size(), 3 );
2482 BOOST_CHECK_EQUAL( messages[0], wxS( "Update BOM\n\nSecond pass." ) );
2483 BOOST_CHECK_EQUAL( messages[1], wxS( "Route power" ) );
2484 BOOST_CHECK_EQUAL( messages[2], wxS( "initial" ) );
2485
2486 BOOST_CHECK_EQUAL( common.GetRecentCommitMessages( 1 ).size(), 1 );
2487 BOOST_CHECK( KIGIT_COMMON( nullptr ).GetRecentCommitMessages( 10 ).empty() );
2488
2489 // A repository with no commit yet has nothing to offer either.
2490 KI_TEST::SCOPED_TEMP_DIR empty( wxS( "kicad-qa-git-empty" ) );
2491 git_repository* unborn = nullptr;
2492 BOOST_REQUIRE_EQUAL( git_repository_init( &unborn, empty.PathStr().utf8_string().c_str(), 0 ), GIT_OK );
2493 KIGIT::GitRepositoryPtr unbornPtr( unborn );
2494 BOOST_CHECK( KIGIT_COMMON( unborn ).GetRecentCommitMessages( 10 ).empty() );
2495}
2496
2497
2498// The docked panel composes the message from a summary line, a description box and a sign off
2499// tick, and takes an amended commit's message apart again to refill those boxes.
2500BOOST_AUTO_TEST_CASE( ComposeAndSplitCommitMessage )
2501{
2502 wxString signOff = wxS( "QA Test <[email protected]>" );
2503
2504 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( " Fix the thing " ), wxEmptyString, wxEmptyString ),
2505 wxS( "Fix the thing" ) );
2506 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( "Fix" ), wxS( "Why it broke.\n" ), wxEmptyString ),
2507 wxS( "Fix\n\nWhy it broke." ) );
2508 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( "Fix" ), wxEmptyString, signOff ),
2509 wxS( "Fix\n\nSigned-off-by: QA Test <[email protected]>" ) );
2510 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( "Fix" ), wxS( "Why." ), signOff ),
2511 wxS( "Fix\n\nWhy.\n\nSigned-off-by: QA Test <[email protected]>" ) );
2512
2513 // An amended message already carries its sign off, even with a trailer after it, and
2514 // someone else's sign off is not ours.
2515 wxString signedOff = wxS( "Why.\n\nSigned-off-by: QA Test <[email protected]>" );
2516 wxString coauthor = wxS( "\nCo-authored-by: X <[email protected]>" );
2517 wxString other = wxS( "Signed-off-by: Other <[email protected]>" );
2518
2519 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( "Fix" ), signedOff, signOff ), wxS( "Fix\n\n" ) + signedOff );
2520 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( "Fix" ), signedOff + wxS( "\n" ), signOff ),
2521 wxS( "Fix\n\n" ) + signedOff );
2522 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( "Fix" ), signedOff + coauthor, signOff ),
2523 wxS( "Fix\n\n" ) + signedOff + coauthor );
2524 BOOST_CHECK_EQUAL( KIGIT::ComposeCommitMessage( wxS( "Fix" ), other, signOff ),
2525 wxS( "Fix\n\n" ) + other + wxS( "\n\nSigned-off-by: QA Test <[email protected]>" ) );
2526
2527 wxString summary, description;
2528
2529 KIGIT::SplitCommitMessage( wxS( "Fix\n\nWhy it broke.\nAnd how.\n" ), summary, description );
2530 BOOST_CHECK_EQUAL( summary, wxS( "Fix" ) );
2531 BOOST_CHECK_EQUAL( description, wxS( "Why it broke.\nAnd how." ) );
2532
2533 KIGIT::SplitCommitMessage( wxS( "Just a line\n" ), summary, description );
2534 BOOST_CHECK_EQUAL( summary, wxS( "Just a line" ) );
2535 BOOST_CHECK( description.IsEmpty() );
2536
2537 // A two line message with no blank line keeps its second line as the description.
2538 KIGIT::SplitCommitMessage( wxS( "First\nSecond" ), summary, description );
2539 BOOST_CHECK_EQUAL( summary, wxS( "First" ) );
2540 BOOST_CHECK_EQUAL( description, wxS( "Second" ) );
2541
2542 // A message read back from a Windows checkout can carry carriage returns.
2543 KIGIT::SplitCommitMessage( wxS( "Fix\r\n\r\nWhy.\r\n" ), summary, description );
2544 BOOST_CHECK_EQUAL( summary, wxS( "Fix" ) );
2545 BOOST_CHECK_EQUAL( description, wxS( "Why." ) );
2546}
2547
2548
2549// Committing to a new branch creates it at HEAD and switches before the commit. The switch
2550// must leave the uncommitted work in place, since the two branches share the same tree.
2551BOOST_AUTO_TEST_CASE( SwitchToBranchAtHeadKeepsUncommittedChanges )
2552{
2553 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2554 KIGIT::GitRepositoryPtr repoPtr( repo );
2555
2556 writeFile( TempPath(), "file.txt", "v2\n" );
2557
2558 KIGIT_COMMON common( repo );
2559 BOOST_REQUIRE( common.CreateBranch( "topic" ) );
2560
2561 GIT_BRANCH_HANDLER handler( &common );
2562 BOOST_REQUIRE_MESSAGE( handler.SwitchToBranch( wxS( "topic" ) ) == BranchResult::Success,
2563 handler.GetErrorString() );
2564
2565 BOOST_CHECK_EQUAL( common.GetCurrentBranchName(), wxS( "topic" ) );
2566 BOOST_CHECK_EQUAL( readWorkingFile( wxFileName( TempPath(), "file.txt" ) ), "v2\n" );
2567 BOOST_CHECK( common.HasUncommittedTrackedChanges() );
2568}
2569
2570
2571// The stored connection string must not carry the secret. A scp-style git@host is left intact.
2572BOOST_AUTO_TEST_CASE( SetRemoteStripsUrlUserinfo )
2573{
2574 KIGIT_COMMON common( nullptr );
2575
2576 common.SetRemote( wxS( "https://user:[email protected]/x/y.git" ) );
2577 BOOST_CHECK_EQUAL( common.GetRemote().ToStdString(), "https://gitlab.com/x/y.git" );
2578
2579 common.SetRemote( wxS( "[email protected]:x/y.git" ) );
2580 BOOST_CHECK_EQUAL( common.GetRemote().ToStdString(), "[email protected]:x/y.git" );
2581}
2582
2583
2584// A password may contain '@'. Splitting on the first one would leave part of the secret.
2585BOOST_AUTO_TEST_CASE( SetRemoteStripsUserinfoContainingAtSign )
2586{
2587 KIGIT_COMMON common( nullptr );
2588
2589 common.SetRemote( wxS( "https://alice:pa@[email protected]/x/y.git" ) );
2590 BOOST_CHECK_EQUAL( common.GetRemote().ToStdString(), "https://gitlab.com/x/y.git" );
2591
2592 // The credentials must also be split at the right place.
2593 BOOST_CHECK_EQUAL( common.GetUsername().ToStdString(), "alice" );
2594 BOOST_CHECK_EQUAL( common.GetPassword().ToStdString(), "pa@ss" );
2595
2596 // An '@' that belongs to the path is not userinfo.
2597 common.SetRemote( wxS( "https://gitlab.com/x/[email protected]" ) );
2598 BOOST_CHECK_EQUAL( common.GetRemote().ToStdString(), "https://gitlab.com/x/[email protected]" );
2599}
2600
2601
2602BOOST_AUTO_TEST_CASE( ParseRemoteURLPreservesSSHAddress )
2603{
2604 struct TEST_CASE
2605 {
2606 const char* url;
2607 const char* username;
2608 const char* hostname;
2609 };
2610
2611 const TEST_CASE cases[] = {
2612 { "ssh://[email protected]:29022/srv/git/repos/l3-auth-30.git", "runner", "192.168.1.55" },
2613 { "ssh://192.168.1.55:29022/srv/git/repos/l3-auth-30.git", "", "192.168.1.55" },
2614 { "git+ssh://[email protected]:2222/srv/repo", "runner", "example.com" },
2615 { "[email protected]:team/repo.git", "git", "example.com" },
2616 { "[email protected]:team/repo", "runner", "example.com" },
2617 { "example.com:team/repo", "", "example.com" },
2618 { "ssh://runner@[2001:db8::1]:2222/srv/repo", "runner", "2001:db8::1" },
2619 { "runner@[2001:db8::1]:team/repo", "runner", "2001:db8::1" },
2620 };
2621
2622 for( const TEST_CASE& test : cases )
2623 {
2625 {
2626 KIGIT_COMMON::REMOTE_URL parsed = KIGIT_COMMON::ParseRemoteURL( wxString::FromUTF8( test.url ) );
2627
2628 BOOST_CHECK_EQUAL( parsed.url.ToStdString(), test.url );
2629 BOOST_CHECK_EQUAL( parsed.username.ToStdString(), test.username );
2630 BOOST_CHECK_EQUAL( parsed.hostname.ToStdString(), test.hostname );
2631 BOOST_CHECK( parsed.password.IsEmpty() );
2632 BOOST_CHECK( parsed.connType == KIGIT_COMMON::GIT_CONN_TYPE::GIT_CONN_SSH );
2633 }
2634 }
2635}
2636
2637
2638BOOST_AUTO_TEST_CASE( ParseRemoteURLKeepsPortWhenExtractingCredentials )
2639{
2640 struct TEST_CASE
2641 {
2642 const char* input;
2643 const char* address;
2644 const char* username;
2645 const char* password;
2647 };
2648
2649 const TEST_CASE cases[] = {
2650 { "https://alice:pa@[email protected]:8443/team/[email protected]", "https://example.com:8443/team/[email protected]",
2652 { "http://alice:[email protected]:8080/team/repo", "http://example.com:8080/team/repo", "alice", "secret",
2654 { "https://example.com:8443/team/[email protected]", "https://example.com:8443/team/[email protected]", "", "",
2656 { "ssh://runner:pa@[email protected]:29022/srv/repo", "ssh://[email protected]:29022/srv/repo", "runner", "pa@ss",
2658 };
2659
2660 for( const TEST_CASE& test : cases )
2661 {
2662 BOOST_TEST_CONTEXT( test.input )
2663 {
2664 KIGIT_COMMON::REMOTE_URL parsed = KIGIT_COMMON::ParseRemoteURL( wxString::FromUTF8( test.input ) );
2665
2666 BOOST_CHECK_EQUAL( parsed.url.ToStdString(), test.address );
2667 BOOST_CHECK_EQUAL( parsed.username.ToStdString(), test.username );
2668 BOOST_CHECK_EQUAL( parsed.password.ToStdString(), test.password );
2669 BOOST_CHECK_EQUAL( parsed.hostname.ToStdString(), "example.com" );
2670 BOOST_CHECK( parsed.connType == test.connType );
2671 }
2672 }
2673}
2674
2675
2676BOOST_AUTO_TEST_CASE( ParseRemoteURLKeepsLocalPaths )
2677{
2678 const char* paths[] = {
2679 "/srv/git/repos/project.git", "../repos/project.git",
2680 "./repos/project:backup.git", "C:/repos/project.git",
2681 "C:\\repos\\project.git", "C:project.git",
2682 "\\\\server\\repos\\project.git", "file:///srv/git/repos/project.git",
2683 "file:///C:/repos/project.git",
2684 };
2685
2686 for( const char* path : paths )
2687 {
2689 {
2690 KIGIT_COMMON::REMOTE_URL parsed = KIGIT_COMMON::ParseRemoteURL( wxString::FromUTF8( path ) );
2691
2692 BOOST_CHECK_EQUAL( parsed.url.ToStdString(), path );
2693 BOOST_CHECK( parsed.username.IsEmpty() );
2694 BOOST_CHECK( parsed.password.IsEmpty() );
2695 BOOST_CHECK( parsed.hostname.IsEmpty() );
2697 }
2698 }
2699}
2700
2701
2702BOOST_AUTO_TEST_CASE( SetRemotePreservesSSHAddressAndAcceptsLocalReplacement )
2703{
2704 KIGIT_COMMON common( nullptr );
2705 const wxString sshURL = wxS( "ssh://[email protected]:29022/srv/git/repos/l3-auth-30.git" );
2706
2707 common.SetUsername( wxS( "previous-user" ) );
2708 common.SetRemote( sshURL );
2709
2710 BOOST_CHECK_EQUAL( common.GetRemote(), sshURL );
2711 BOOST_CHECK_EQUAL( common.GetUsername(), wxS( "runner" ) );
2712 BOOST_CHECK( common.GetConnType() == KIGIT_COMMON::GIT_CONN_TYPE::GIT_CONN_SSH );
2713
2714 common.SetRemote( wxS( "/srv/git/repos/local.git" ) );
2715
2716 BOOST_CHECK_EQUAL( common.GetRemote(), wxS( "/srv/git/repos/local.git" ) );
2718}
2719
2720
2721// Called after editing a remote, so a later push authenticates against the new server.
2722BOOST_AUTO_TEST_CASE( UpdateCurrentBranchInfoRefreshesChangedRemoteUrl )
2723{
2724 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2725 KIGIT::GitRepositoryPtr repoPtr( repo );
2726
2727 KIGIT_COMMON common( repo );
2728 BOOST_REQUIRE( common.AddRemote( "origin", "https://example.com/old.git" ) );
2729
2730 git_config* cfg = nullptr;
2731 BOOST_REQUIRE_EQUAL( git_repository_config( &cfg, repo ), GIT_OK );
2732 KIGIT::GitConfigPtr cfgPtr( cfg );
2733 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.remote", "origin" ), GIT_OK );
2734 BOOST_REQUIRE_EQUAL( git_config_set_string( cfg, "branch.main.merge", "refs/heads/main" ), GIT_OK );
2735
2736 common.UpdateCurrentBranchInfo();
2737 BOOST_CHECK_EQUAL( common.GetRemote().ToStdString(), "https://example.com/old.git" );
2738
2739 BOOST_REQUIRE( common.SetRemoteUrl( "origin", "https://example.com/new.git" ) );
2740 common.UpdateCurrentBranchInfo();
2741 BOOST_CHECK_EQUAL( common.GetRemote().ToStdString(), "https://example.com/new.git" );
2742}
2743
2744
2745// An empty result means the end of the list, so an empty entry would hide every key behind it.
2746BOOST_AUTO_TEST_CASE( SetSSHKeyIgnoresEmptyKey )
2747{
2748 // Only keys with both files on disk are offered
2749 KI_TEST::WriteGitFile( TempPath(), wxS( "keys/kicad" ), c_opensshPlain );
2750 KI_TEST::WriteGitFile( TempPath(), wxS( "keys/kicad.pub" ), "ssh-ed25519 AAAA qa\n" );
2751
2752 const wxString key = wxFileName( TempPath() + wxS( "/keys/kicad" ) ).GetFullPath();
2753 KIGIT_COMMON common( nullptr );
2754
2755 common.SetSSHKey( key );
2756 common.SetSSHKey( wxEmptyString );
2757 common.ResetNextKey();
2758
2759 BOOST_CHECK_EQUAL( common.GetNextPublicKey(), key );
2760}
2761
2762
2763// libgit2 reports forward slashes on every platform while the project path is native. On Windows
2764// the raw prefix test rejected every file and the commit panel stayed empty. A backslash is an
2765// ordinary file name character elsewhere, so only Windows folds the separators.
2766BOOST_AUTO_TEST_CASE( IsWithinProjectPathHandlesMixedSeparators )
2767{
2769
2770#ifdef __WINDOWS__
2771 BOOST_CHECK(
2772 PROJECT_GIT_UTILS::IsWithinProjectPath( wxS( "C:/repo/proj/board.kicad_pcb" ), wxS( "C:\\repo\\proj" ) ) );
2773 BOOST_CHECK( PROJECT_GIT_UTILS::IsWithinProjectPath( wxS( "C:/repo/proj/sub/sheet.kicad_sch" ),
2774 wxS( "C:\\repo\\proj\\" ) ) );
2775 BOOST_CHECK( !PROJECT_GIT_UTILS::IsWithinProjectPath( wxS( "C:/repo/other/board.kicad_pcb" ),
2776 wxS( "C:\\repo\\proj" ) ) );
2777#endif
2778
2779 // A sibling directory sharing the prefix as plain text is not inside the project.
2780 BOOST_CHECK( !PROJECT_GIT_UTILS::IsWithinProjectPath( wxS( "/repo/project2/file" ), wxS( "/repo/project" ) ) );
2781 BOOST_CHECK( PROJECT_GIT_UTILS::IsWithinProjectPath( wxS( "/repo/project/file" ), wxS( "/repo/project" ) ) );
2782}
2783
2784
2785// The pull gate refuses over uncommitted tracked changes, but untracked files must not block
2786// getting changes, since a merge leaves them alone.
2787BOOST_AUTO_TEST_CASE( HasUncommittedTrackedChangesIgnoresUntracked )
2788{
2789 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "v1\n" );
2790 KIGIT::GitRepositoryPtr repoPtr( repo );
2791
2792 KIGIT_COMMON common( repo );
2793 BOOST_CHECK( !common.HasUncommittedTrackedChanges() );
2794
2795 // An untracked file alone does not count.
2796 {
2797 wxFileName fn( TempPath(), wxS( "notes.txt" ) );
2798 std::ofstream f( fn.GetFullPath().utf8_string() );
2799 f << "scratch\n";
2800 }
2801
2802 BOOST_CHECK( !common.HasUncommittedTrackedChanges() );
2803
2804 // A modified tracked file does.
2805 {
2806 wxFileName fn( TempPath(), wxS( "file.txt" ) );
2807 std::ofstream f( fn.GetFullPath().utf8_string() );
2808 f << "v2\n";
2809 }
2810
2811 BOOST_CHECK( common.HasUncommittedTrackedChanges() );
2812}
2813
2814
2815BOOST_AUTO_TEST_CASE( WorktreeRefusalPrioritizesUnsavedDocumentAndLibraryEditors )
2816{
2817 GIT_WORKTREE_STATUS status;
2818 status.checked = true;
2819 status.hasTrackedChanges = true;
2820 status.lfsMessage = wxS( "Git LFS prevents this rewrite." );
2821 status.outsideProject = { wxS( "other/board.kicad_pcb" ) };
2822
2823 const std::vector<wxString> editors = { wxS( "Schematic Editor" ), wxS( "Footprint Editor" ) };
2824 wxString message = status.RefusalMessage( editors );
2825
2826 BOOST_CHECK( message.Contains( "Schematic Editor" ) );
2827 BOOST_CHECK( message.Contains( "Footprint Editor" ) );
2828 BOOST_CHECK( message.Contains( "unsaved changes" ) );
2829 BOOST_CHECK( message.Contains( "Save or discard" ) );
2830 BOOST_CHECK( !message.Contains( "Git LFS" ) );
2831 BOOST_CHECK( !message.Contains( "other/board.kicad_pcb" ) );
2832
2833 wxString stashMessage = status.RefusalMessage( editors, true );
2834 BOOST_CHECK( stashMessage.Contains( "Schematic Editor" ) );
2835 BOOST_CHECK( stashMessage.Contains( "Footprint Editor" ) );
2836 BOOST_CHECK( stashMessage.Contains( "unsaved changes" ) );
2837 BOOST_CHECK( stashMessage.Contains( "Save or discard" ) );
2838 BOOST_CHECK( stashMessage.Contains( "before stashing" ) );
2839
2840 // A failed disk check must still explain the unsaved work the user can resolve first.
2841 status.checked = false;
2842 BOOST_CHECK_EQUAL( status.RefusalMessage( editors ).ToStdString(), message.ToStdString() );
2843}
2844
2845
2846BOOST_AUTO_TEST_CASE( WorktreeStatusRefusesUnavailableAndFailedReads )
2847{
2848 KIGIT_COMMON unavailable( nullptr );
2849 auto unavailableLock = unavailable.LockAction();
2850
2851 BOOST_CHECK( !GIT_WORKTREE_STATUS{}.RefusalMessage( {} ).IsEmpty() );
2852
2853 GIT_WORKTREE_STATUS missing = ReadGitWorktreeStatus( unavailable, wxEmptyString );
2854 BOOST_CHECK( !missing.checked );
2855 BOOST_CHECK( missing.RefusalMessage( {} ).Contains( "Could not check" ) );
2856 BOOST_CHECK( !missing.RefusalMessage( {} ).Contains( "Stash Changes" ) );
2857 BOOST_CHECK( !missing.RefusalMessage( {}, true ).IsEmpty() );
2858
2859 // A repository without a working tree makes libgit2's status read fail.
2860 git_repository* rawRepo = nullptr;
2861 BOOST_REQUIRE_EQUAL( git_repository_init( &rawRepo, TempPath().utf8_str(), 1 ), GIT_OK );
2862 KIGIT::GitRepositoryPtr repo( rawRepo );
2863 KIGIT_COMMON common( repo.get() );
2864 auto lock = common.LockAction();
2865
2866 GIT_WORKTREE_STATUS failed = ReadGitWorktreeStatus( common, TempPath() );
2867 BOOST_CHECK( !failed.checked );
2868 BOOST_CHECK( failed.RefusalMessage( {} ).Contains( "Could not check" ) );
2869}
2870
2871
2872BOOST_AUTO_TEST_CASE( WorktreeStatusAllowsCleanAndUntrackedFiles )
2873{
2874 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "board.kicad_pcb", "original\n" ) );
2875 KIGIT_COMMON common( repo.get(), TempPath() );
2876 auto lock = common.LockAction();
2877
2878 GIT_WORKTREE_STATUS clean = ReadGitWorktreeStatus( common, TempPath() );
2879 BOOST_CHECK( clean.checked );
2880 BOOST_CHECK( !clean.hasTrackedChanges );
2881 BOOST_CHECK( clean.RefusalMessage( {} ).IsEmpty() );
2882
2883 writeFile( TempPath(), "notes.txt", "untracked notes\n" );
2884
2885 GIT_WORKTREE_STATUS untracked = ReadGitWorktreeStatus( common, TempPath() );
2886 BOOST_CHECK( untracked.checked );
2887 BOOST_CHECK( !untracked.hasTrackedChanges );
2888 BOOST_CHECK( untracked.outsideProject.empty() );
2889 BOOST_CHECK( untracked.RefusalMessage( {} ).IsEmpty() );
2890}
2891
2892
2893BOOST_AUTO_TEST_CASE( WorktreeStatusExplainsTrackedChangesInsideAndOutsideTheProject )
2894{
2895 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "README.txt", "repository\n" ) );
2896
2897 const wxString projectPath = TempPath() + wxS( "/project" );
2898 BOOST_REQUIRE( wxFileName::Mkdir( projectPath, wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL ) );
2899 BOOST_REQUIRE( wxFileName::Mkdir( TempPath() + wxS( "/project2" ), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL ) );
2900 writeFile( TempPath(), "project/board.kicad_pcb", "project board\n" );
2901 commitWorkingFile( repo.get(), "project/board.kicad_pcb", "add project" );
2902 writeFile( TempPath(), "project2/board.kicad_pcb", "sibling board\n" );
2903 commitWorkingFile( repo.get(), "project2/board.kicad_pcb", "add sibling" );
2904
2905 KIGIT_COMMON common( repo.get(), projectPath );
2906 auto lock = common.LockAction();
2907 writeFile( TempPath(), "project/board.kicad_pcb", "edited project board\n" );
2908
2909 GIT_WORKTREE_STATUS inside = ReadGitWorktreeStatus( common, projectPath );
2910 BOOST_CHECK( inside.checked );
2911 BOOST_CHECK( inside.hasTrackedChanges );
2912 BOOST_CHECK( inside.outsideProject.empty() );
2913 BOOST_CHECK( inside.RefusalMessage( {} ).Contains( "Stash Changes" ) );
2914 BOOST_CHECK( inside.RefusalMessage( {}, true ).IsEmpty() );
2915
2916 writeFile( TempPath(), "project2/board.kicad_pcb", "edited sibling board\n" );
2917 writeFile( TempPath(), "project2/notes.txt", "untracked sibling notes\n" );
2918
2919 GIT_WORKTREE_STATUS outside = ReadGitWorktreeStatus( common, projectPath );
2920 BOOST_CHECK( outside.checked );
2921 BOOST_CHECK( outside.hasTrackedChanges );
2922 BOOST_REQUIRE_EQUAL( outside.outsideProject.size(), 1 );
2923 BOOST_CHECK_EQUAL( outside.outsideProject.front().ToStdString(), "project2/board.kicad_pcb" );
2924
2925 wxString message = outside.RefusalMessage( {} );
2926 BOOST_CHECK( message.Contains( "project2/board.kicad_pcb" ) );
2927 BOOST_CHECK( !message.Contains( "project/board.kicad_pcb" ) );
2928 BOOST_CHECK( !message.Contains( "notes.txt" ) );
2929 BOOST_CHECK( outside.RefusalMessage( {}, true ).IsEmpty() );
2930}
2931
2932
2933BOOST_AUTO_TEST_CASE( WorktreeStatusDistinguishesGitLfsFromUncommittedChanges )
2934{
2935 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "board.kicad_pcb", "original\n" ) );
2936
2937 writeFile( TempPath(), ".gitattributes", "*.step filter=lfs diff=lfs merge=lfs -text\n" );
2938 commitWorkingFile( repo.get(), ".gitattributes", "route step files through lfs" );
2939 writeFile( TempPath(), "case.step", "ISO-10303-21;\n" );
2940 commitWorkingFile( repo.get(), "case.step", "add the enclosure" );
2941
2942 KIGIT_COMMON common( repo.get(), TempPath() );
2943 auto lock = common.LockAction();
2944
2945 GIT_WORKTREE_STATUS clean = ReadGitWorktreeStatus( common, TempPath() );
2946 BOOST_CHECK( clean.checked );
2947 BOOST_CHECK( !clean.hasTrackedChanges );
2948 BOOST_CHECK( clean.RefusalMessage( {} ).Contains( "Git LFS" ) );
2949 BOOST_CHECK( clean.RefusalMessage( {} ).Contains( "case.step" ) );
2950
2951 writeFile( TempPath(), "board.kicad_pcb", "edited board\n" );
2952
2953 GIT_WORKTREE_STATUS dirty = ReadGitWorktreeStatus( common, TempPath() );
2954 BOOST_CHECK( dirty.checked );
2955 BOOST_CHECK( dirty.hasTrackedChanges );
2956 BOOST_CHECK( dirty.RefusalMessage( {} ).Contains( "Git LFS" ) );
2957 BOOST_CHECK( !dirty.RefusalMessage( {} ).Contains( "Stash Changes" ) );
2958 BOOST_CHECK( dirty.RefusalMessage( {}, true ).Contains( "Git LFS" ) );
2959}
2960
2961
2962// Discarding a conflicted file puts back what HEAD holds and clears the conflict, so it settles
2963// the file rather than only throwing away an edit. The panel says so, and asks first.
2964BOOST_AUTO_TEST_CASE( DiscardingAConflictedPathSettlesIt )
2965{
2966 git_repository* repo = makeRepoWithCommit( TempPath(), "file.txt", "one\ntwo\nthree\n" );
2967 KIGIT::GitRepositoryPtr repoPtr( repo );
2968
2969 KIGIT_COMMON common( repo );
2970 BOOST_CHECK( !common.IsPathConflicted( wxS( "file.txt" ) ) );
2971 BOOST_CHECK( !common.IsPathConflicted( wxS( "nothing.txt" ) ) );
2972
2973 git_oid blobOid;
2974 BOOST_REQUIRE_EQUAL( git_blob_create_from_buffer( &blobOid, repo, "other\n", 6 ), GIT_OK );
2975
2976 {
2977 git_index* index = nullptr;
2978 BOOST_REQUIRE_EQUAL( git_repository_index( &index, repo ), GIT_OK );
2979 KIGIT::GitIndexPtr indexPtr( index );
2980
2981 git_index_entry stage{};
2982 stage.path = "file.txt";
2983 stage.mode = GIT_FILEMODE_BLOB;
2984 git_oid_cpy( &stage.id, &blobOid );
2985
2986 BOOST_REQUIRE_EQUAL( git_index_conflict_add( index, &stage, &stage, &stage ), GIT_OK );
2987 BOOST_REQUIRE_EQUAL( git_index_write( index ), GIT_OK );
2988 }
2989
2990 BOOST_CHECK( common.IsPathConflicted( wxS( "file.txt" ) ) );
2991
2992 wxString error;
2993 BOOST_REQUIRE_MESSAGE( common.DiscardChanges( { wxS( "file.txt" ) }, error ), error.ToStdString() );
2994
2995 BOOST_CHECK_MESSAGE( !common.IsPathConflicted( wxS( "file.txt" ) ),
2996 "discarding left the file still needing resolution" );
2997}
2998
2999
3000BOOST_AUTO_TEST_CASE( ForcePushRejectsAChangedConfirmedTarget )
3001{
3002 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3003 KIGIT_COMMON common( repo.get() );
3004
3005 git_oid head;
3006 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &head, repo.get(), "HEAD" ), GIT_OK );
3007 const wxString confirmedHead = wxString::FromUTF8( git_oid_tostr_s( &head ) );
3008 const wxString confirmedBranch = common.GetCurrentBranchName();
3009
3010 auto forcePushError = [&common]( const wxString& aBranch, const wxString& aHeadOid )
3011 {
3012 GIT_PUSH_HANDLER handler( &common );
3013 handler.SetExpectedTarget( aBranch, aHeadOid );
3014 BOOST_CHECK_EQUAL( static_cast<int>( handler.PerformPush( true ) ), static_cast<int>( PushResult::Error ) );
3015 return handler.GetErrorString();
3016 };
3017
3018 // A matching target gets past confirmation validation. With no remote configured it
3019 // stops at lookup, so none of these checks can start a network connection.
3020 const wxString noRemote = wxS( "There is no remote named 'origin'." );
3021 BOOST_CHECK( forcePushError( confirmedBranch, confirmedHead ).StartsWith( noRemote ) );
3022
3023 const wxString targetChanged = wxS( "The branch or its last commit changed while the push was waiting." );
3024
3025 // Switching branches must be rejected even when both branches name the same commit.
3026 BOOST_REQUIRE( common.CreateBranch( wxS( "other" ) ) );
3027 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo.get(), "refs/heads/other" ), GIT_OK );
3028 BOOST_CHECK( forcePushError( confirmedBranch, confirmedHead ).Contains( targetChanged ) );
3029
3030 // Advancing the confirmed branch must also be rejected, with the branch name unchanged.
3031 BOOST_REQUIRE_EQUAL( git_repository_set_head( repo.get(), "refs/heads/main" ), GIT_OK );
3032 writeFile( TempPath(), wxS( "file.txt" ), "two\n" );
3033 commitWorkingFile( repo.get(), wxS( "file.txt" ), "second" );
3034 BOOST_CHECK( forcePushError( confirmedBranch, confirmedHead ).Contains( targetChanged ) );
3035
3036 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &head, repo.get(), "HEAD" ), GIT_OK );
3037 BOOST_CHECK(
3038 forcePushError( confirmedBranch, wxString::FromUTF8( git_oid_tostr_s( &head ) ) ).StartsWith( noRemote ) );
3039}
3040
3041
3042BOOST_AUTO_TEST_CASE( PushWaitsForQueryLockAndHonorsCancellation )
3043{
3044 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3045 KIGIT_COMMON common( repo.get() );
3046 GIT_PUSH_HANDLER handler( &common );
3047 handler.SetWaitForActionLock( true );
3048
3050 handler.SetCancelToken( token );
3051
3052 auto lock = common.LockAction();
3053 std::promise<void> entered;
3054 auto ready = entered.get_future();
3055 auto result = std::async( std::launch::async,
3056 [&handler, &entered]()
3057 {
3058 entered.set_value();
3059 return handler.PerformPush();
3060 } );
3061
3062 const bool started = ready.wait_for( std::chrono::seconds( 5 ) ) == std::future_status::ready;
3063 const bool waited = result.wait_for( std::chrono::milliseconds( 50 ) ) == std::future_status::timeout;
3064 token->store( true );
3065 lock.unlock();
3066
3067 BOOST_CHECK( started );
3068 BOOST_CHECK( waited );
3069 BOOST_REQUIRE( result.wait_for( std::chrono::seconds( 5 ) ) == std::future_status::ready );
3070 BOOST_CHECK_EQUAL( static_cast<int>( result.get() ), static_cast<int>( PushResult::Error ) );
3071 BOOST_CHECK( handler.IsThisOperationCancelled() );
3072
3073 // No remote is configured. Reaching remote lookup would have added an error, so an empty
3074 // error list confirms cancellation stopped the operation before any network preparation.
3075 BOOST_CHECK( handler.GetErrorStrings().empty() );
3076 BOOST_CHECK_EQUAL( common.GetRecentCommits( 1 ).size(), 1u );
3077}
3078
3079
3080BOOST_AUTO_TEST_CASE( TransferCompletionCapturesStayOnUiThread )
3081{
3082 auto wrongThread = std::make_shared<std::atomic<bool>>( false );
3083 UI_CALLBACK_CAPTURE capture( wrongThread );
3084 bool completed = false;
3085
3086 {
3087 KIGIT_TRANSFER transfer;
3088 BOOST_REQUIRE( transfer.Start(
3089 "completion capture",
3090 []( const KIGIT_COMMON::CANCEL_TOKEN& )
3091 {
3092 },
3093 [capture, &completed]()
3094 {
3095 completed = true;
3096 } ) );
3097
3098 BOOST_REQUIRE( waitForQuery(
3099 [&transfer]()
3100 {
3101 return !transfer.IsBusy();
3102 } ) );
3103 }
3104
3105 BOOST_CHECK( completed );
3106 BOOST_CHECK( !wrongThread->load() );
3107
3108 auto entered = std::make_shared<std::promise<void>>();
3109 std::promise<void> release;
3110 auto ready = entered->get_future();
3111 auto proceed = release.get_future().share();
3112 auto transfer = std::make_unique<KIGIT_TRANSFER>();
3113 completed = false;
3114
3115 BOOST_REQUIRE( transfer->Start(
3116 "destroyed completion owner",
3117 [entered, proceed]( const KIGIT_COMMON::CANCEL_TOKEN& )
3118 {
3119 entered->set_value();
3120 proceed.wait();
3121 },
3122 [capture, &completed]()
3123 {
3124 completed = true;
3125 } ) );
3126
3127 const bool started = ready.wait_for( std::chrono::seconds( 5 ) ) == std::future_status::ready;
3128 transfer.reset();
3129 release.set_value();
3130
3131 BOOST_CHECK( started );
3132 BOOST_REQUIRE_EQUAL( Backend()->OrphanRegistry().JoinAll( std::chrono::seconds( 5 ) ), 0u );
3133 wxTheApp->ProcessPendingEvents();
3134 BOOST_CHECK( !completed );
3135 BOOST_CHECK( !wrongThread->load() );
3136}
3137
3138
3139BOOST_AUTO_TEST_CASE( QuerySerializesAndKeepsOnlyTheLatestRequest )
3140{
3141 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3142 auto session = std::make_shared<KIGIT_COMMON>( repo.get() );
3143 KIGIT_QUERY query;
3144
3145 auto entered = std::make_shared<std::promise<void>>();
3146 std::promise<void> release;
3147 auto ready = entered->get_future();
3148 auto proceed = release.get_future().share();
3149 auto worked = std::make_shared<std::atomic<unsigned>>( 0 );
3150 auto workerThread = std::make_shared<std::thread::id>();
3151 unsigned completed = 0;
3152
3153 query.Run(
3154 session,
3155 [worked, workerThread, proceed, entered]( KIGIT_COMMON& )
3156 {
3157 *workerThread = std::this_thread::get_id();
3158 worked->fetch_or( 1 );
3159 entered->set_value();
3160 proceed.wait();
3161 },
3162 [&completed]()
3163 {
3164 completed |= 1;
3165 } );
3166
3167 BOOST_REQUIRE( ready.wait_for( std::chrono::seconds( 5 ) ) == std::future_status::ready );
3168 BOOST_CHECK( *workerThread != std::this_thread::get_id() );
3169 BOOST_CHECK( !session->TryLockAction().owns_lock() );
3170
3171 query.Run(
3172 session,
3173 [worked]( KIGIT_COMMON& )
3174 {
3175 worked->fetch_or( 2 );
3176 },
3177 [&completed]()
3178 {
3179 completed |= 2;
3180 } );
3181 query.Run(
3182 session,
3183 [worked]( KIGIT_COMMON& )
3184 {
3185 worked->fetch_or( 4 );
3186 },
3187 [&completed]()
3188 {
3189 completed |= 4;
3190 } );
3191 release.set_value();
3192
3193 BOOST_REQUIRE( waitForQuery(
3194 [&query]()
3195 {
3196 return !query.IsBusy();
3197 } ) );
3198 BOOST_CHECK_EQUAL( worked->load(), 5u );
3199 BOOST_CHECK_EQUAL( completed, 4u );
3200}
3201
3202
3203BOOST_AUTO_TEST_CASE( QueryCancelDropsPendingWorkAndQueuedCompletion )
3204{
3205 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3206 auto session = std::make_shared<KIGIT_COMMON>( repo.get() );
3207 KIGIT_QUERY query;
3208 bool completed = false;
3209 auto worked = std::make_shared<std::atomic<unsigned>>( 0 );
3210 std::shared_ptr<int> workerLifetime = std::make_shared<int>( 0 );
3211 std::weak_ptr<int> workerWatch = workerLifetime;
3212
3213 query.Run(
3214 session,
3215 [worked, workerLifetime]( KIGIT_COMMON& )
3216 {
3217 worked->fetch_add( 1 );
3218 },
3219 [&completed]()
3220 {
3221 completed = true;
3222 } );
3223 workerLifetime.reset();
3224
3225 // The worker drops its capture after posting completion. Leave that event queued.
3226 const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds( 5 );
3227
3228 while( !workerWatch.expired() && std::chrono::steady_clock::now() < deadline )
3229 std::this_thread::sleep_for( std::chrono::milliseconds( 1 ) );
3230
3231 BOOST_REQUIRE( workerWatch.expired() );
3232 query.Run(
3233 session,
3234 [worked]( KIGIT_COMMON& )
3235 {
3236 worked->fetch_add( 1 );
3237 },
3238 [&completed]()
3239 {
3240 completed = true;
3241 } );
3242 query.Cancel();
3243
3244 BOOST_REQUIRE( waitForQuery(
3245 [&query]()
3246 {
3247 return !query.IsBusy();
3248 } ) );
3249 BOOST_CHECK_EQUAL( worked->load(), 1u );
3250 BOOST_CHECK( !completed );
3251}
3252
3253
3254BOOST_AUTO_TEST_CASE( QueryDestructionDoesNotWaitAndPinsTheSession )
3255{
3256 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3257 auto session = std::make_shared<KIGIT_COMMON>( repo.get() );
3258 std::weak_ptr<KIGIT_COMMON> watched = session;
3259 auto query = std::make_unique<KIGIT_QUERY>();
3260
3261 auto entered = std::make_shared<std::promise<void>>();
3262 std::promise<void> release;
3263 auto ready = entered->get_future();
3264 auto proceed = release.get_future().share();
3265 bool completed = false;
3266
3267 query->Run(
3268 session,
3269 [proceed, entered]( KIGIT_COMMON& )
3270 {
3271 entered->set_value();
3272 proceed.wait();
3273 },
3274 [&completed]()
3275 {
3276 completed = true;
3277 } );
3278
3279 BOOST_REQUIRE( ready.wait_for( std::chrono::seconds( 5 ) ) == std::future_status::ready );
3280 session.reset();
3281 const auto start = std::chrono::steady_clock::now();
3282 query.reset();
3283 BOOST_CHECK( std::chrono::steady_clock::now() - start < std::chrono::seconds( 1 ) );
3284 BOOST_CHECK( !watched.expired() );
3285 release.set_value();
3286
3287 BOOST_REQUIRE( waitForQuery(
3288 [&watched]()
3289 {
3290 return watched.expired();
3291 } ) );
3292 wxTheApp->ProcessPendingEvents();
3293 BOOST_CHECK( !completed );
3294}
3295
3296
3297BOOST_AUTO_TEST_CASE( QuerySkipsUnavailableSessionsAndCompletesOnUi )
3298{
3299 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3300 KIGIT_QUERY query;
3301 auto worked = std::make_shared<std::atomic<bool>>( false );
3302 std::thread::id completionThread;
3303
3304 auto work = [worked]( KIGIT_COMMON& )
3305 {
3306 worked->store( true );
3307 };
3308 auto done = [&completionThread]()
3309 {
3310 completionThread = std::this_thread::get_id();
3311 };
3312
3313 query.Run( nullptr, work, done );
3314 BOOST_REQUIRE( waitForQuery(
3315 [&query]()
3316 {
3317 return !query.IsBusy();
3318 } ) );
3319 BOOST_CHECK( completionThread == std::this_thread::get_id() );
3320
3321 auto session = std::make_shared<KIGIT_COMMON>( nullptr );
3322 completionThread = {};
3323 query.Run( session, work, done );
3324 BOOST_REQUIRE( waitForQuery(
3325 [&query]()
3326 {
3327 return !query.IsBusy();
3328 } ) );
3329 BOOST_CHECK( completionThread == std::this_thread::get_id() );
3330
3331 session->SetRepo( repo.get() );
3332 session->SetShuttingDown( true );
3333 completionThread = {};
3334 query.Run( session, work, done );
3335 BOOST_REQUIRE( waitForQuery(
3336 [&query]()
3337 {
3338 return !query.IsBusy();
3339 } ) );
3340 BOOST_CHECK( completionThread == std::this_thread::get_id() );
3341 BOOST_CHECK( !worked->load() );
3342
3343 Backend()->OrphanRegistry().JoinAll( std::chrono::seconds( 1 ) );
3344 completionThread = {};
3345 query.Run( session, work, done );
3346 BOOST_CHECK( !query.IsBusy() );
3347 BOOST_CHECK( completionThread == std::this_thread::get_id() );
3348}
3349
3350
3351BOOST_AUTO_TEST_CASE( QueryExceptionStillCompletesAndCompletionCanDestroyOwner )
3352{
3353 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3354 auto session = std::make_shared<KIGIT_COMMON>( repo.get() );
3355 auto query = std::make_unique<KIGIT_QUERY>();
3356 wxLogNull noLog;
3357
3358 query->Run(
3359 session,
3360 []( KIGIT_COMMON& )
3361 {
3362 throw std::runtime_error( "query failed" );
3363 },
3364 [&query]()
3365 {
3366 query.reset();
3367 } );
3368
3369 BOOST_REQUIRE( waitForQuery(
3370 [&query]()
3371 {
3372 return !query;
3373 } ) );
3374 BOOST_CHECK( session->TryLockAction().owns_lock() );
3375 BOOST_CHECK_EQUAL( session->GetRecentCommits( 1 ).size(), 1u );
3376}
3377
3378
3379BOOST_AUTO_TEST_CASE( QueryExceptionStillStartsThePendingRequest )
3380{
3381 KIGIT::GitRepositoryPtr repo( makeRepoWithCommit( TempPath(), "file.txt", "one\n" ) );
3382 auto session = std::make_shared<KIGIT_COMMON>( repo.get() );
3383 KIGIT_QUERY query;
3384 wxLogNull noLog;
3385
3386 auto entered = std::make_shared<std::promise<void>>();
3387 std::promise<void> release;
3388 auto ready = entered->get_future();
3389 auto proceed = release.get_future().share();
3390 auto worked = std::make_shared<std::atomic<bool>>( false );
3391 unsigned completed = 0;
3392
3393 query.Run(
3394 session,
3395 [entered, proceed]( KIGIT_COMMON& )
3396 {
3397 entered->set_value();
3398 proceed.wait();
3399 throw 1;
3400 },
3401 [&completed]()
3402 {
3403 completed |= 1;
3404 } );
3405
3406 BOOST_REQUIRE( ready.wait_for( std::chrono::seconds( 5 ) ) == std::future_status::ready );
3407 query.Run(
3408 session,
3409 [worked]( KIGIT_COMMON& )
3410 {
3411 worked->store( true );
3412 },
3413 [&completed]()
3414 {
3415 completed |= 2;
3416 } );
3417 release.set_value();
3418
3419 BOOST_REQUIRE( waitForQuery(
3420 [&query]()
3421 {
3422 return !query.IsBusy();
3423 } ) );
3424 BOOST_CHECK( worked->load() );
3425 BOOST_CHECK_EQUAL( completed, 2u );
3426}
3427
3428
int index
const char * name
BranchResult SwitchToBranch(const wxString &aBranchName)
Switch to the specified branch.
A private scratch directory for extracting committed file versions to diff, removed when this object ...
bool IsOk() const
True when the scratch directory was created.
const wxString & Root() const
The scratch directory, for callers that lay out their own files (e.g. footprint libraries).
wxString Materialize(git_repository *aRepo, const wxString &aRef, const wxString &aRelPath, KICAD_DIFF::DOC_KIND aKind)
Extract aRelPath at aRef into a uniquely named slot and return the path to load, or an empty string w...
void SetExpectedTarget(const wxString &aBranch, const wxString &aHeadOid)
Pin the branch and full HEAD commit ID confirmed by the caller, so a queued push cannot publish a dif...
PushResult PerformPush(bool aForce=false)
Utility class with helper functions for project level git operations.
static bool WriteBlobToFile(git_repository *aRepo, const wxString &aRef, const wxString &aPath, const wxString &aDestPath)
Write a file as it exists at a git ref to a path on disk, so a committed version can be diffed.
static wxString ComputeSymlinkPreservingWorkDir(const wxString &aUserProjectPath, const wxString &aCanonicalWorkDir)
Compute a working directory path that preserves symlinks from the user's project path.
static bool IsWithinProjectPath(const wxString &aAbsPath, const wxString &aProjectPath)
Test whether an absolute file path lives inside the current project directory.
static std::optional< wxString > ProjectPrefix(const wxString &aWorkDir, const wxString &aProjectPath)
The project's folder inside the repository, ending in a slash.
Publishes a token for as long as its operation runs.
The project's git session.
std::pair< int, int > GetWorkingTreeLineStats(const std::set< wxString > &aPaths) const
std::shared_ptr< std::atomic< bool > > CANCEL_TOKEN
One token per operation.
GIT_CONN_TYPE GetConnType() const
std::vector< KIGIT_COMMIT_INFO > GetRecentCommits(size_t aMax=50, const KIGIT_HISTORY_FILTER &aFilter={}) const
bool HasUncommittedTrackedChanges(bool *aReadOk=nullptr) const
bool StashPop(size_t aIndex)
wxString GetRemote() const
wxString GetCurrentBranchName() const
wxString GetGitRootDirectory() const
KIGIT_TAG_DETAIL GetTagDetail(const wxString &aName) const
void SetSSHKey(const wxString &aSSHKey)
std::unique_lock< std::mutex > LockAction()
bool IsPullRebaseConfigured() const
True when the repository is set to rebase rather than merge when getting changes.
KIGIT_COMMIT_DETAIL GetCommitDetail(const wxString &aHash) const
bool HasUpstreamCommits() const
True when the upstream branch has commits the current branch does not.
bool CheckoutTag(const wxString &aName)
void SetUsername(const wxString &aUsername)
bool DeleteTag(const wxString &aName)
std::vector< wxString > GetProjectDirs()
Return a vector of project files in the repository.
std::vector< wxString > GetRecentCommitMessages(size_t aMax) const
bool RemoveRemote(const wxString &aName)
std::optional< size_t > GetUnmergedCommitCount(const wxString &aBranch) const
Commits in aBranch that HEAD does not contain. No value when either cannot be read.
bool CreateBranch(const wxString &aName, const wxString &aFrom=wxEmptyString)
wxString GetNextPublicKey()
By value, so the caller never holds a pointer into the vector.
bool AddRemote(const wxString &aName, const wxString &aUrl)
static CANCEL_TOKEN NewCancelToken()
void AddRefreshListener(void *aOwner, std::function< void()> aCallback)
std::vector< KIGIT_STASH_INFO > GetStashes() const
bool StartUnbornBranch(const wxString &aName)
Point HEAD at a branch that does not exist yet, so the first commit creates it.
std::pair< std::set< wxString >, std::set< wxString > > GetDifferentFiles() const
Return a pair of sets of files that differ locally from the remote repository The first set is files ...
bool IsHeadUnborn() const
True while the repository has no commits, so HEAD names a branch that does not exist yet.
KIGIT_BRANCH_DETAIL GetBranchDetail(const wxString &aBranch) const
bool HasPushAndPullRemote() const
bool StashApply(size_t aIndex)
bool DiscardChanges(const std::vector< wxString > &aPaths, wxString &aError)
Put the given working directory relative paths back to their committed state.
wxString GetUsername() const
bool SetUserConfig(const wxString &aName, const wxString &aEmail)
KIGIT_STASH_DETAIL GetStashDetail(const wxString &aId) const
std::vector< std::pair< wxString, wxString > > GetRemotes() const
Returns every remote as a (name, URL) pair.
bool CreateTag(const wxString &aName, const wxString &aMessage=wxEmptyString)
void RemoveRefreshListener(void *aOwner)
bool StashDrop(size_t aIndex)
bool HasLocalCommits() const
True when HEAD has commits its upstream does not, or has no upstream yet.
bool StashSave(const wxString &aMessage)
Set the uncommitted tracked changes aside as a new stash, as git stash push would.
wxDateTime GetLastFetchTime() const
static REMOTE_URL ParseRemoteURL(const wxString &aURL)
wxString GetPassword()
std::vector< KIGIT_TAG_INFO > GetTags() const
std::vector< KIGIT_BRANCH_INFO > GetBranches() const
Local and remote branches with their tip commits.
bool DeleteBranch(const wxString &aName)
bool IsPathConflicted(const wxString &aPath) const
True while aPath is one of the files a merge or rebase stopped on.
wxString GetRemotename() const
bool IsHeadDetached() const
True when HEAD points at a commit rather than a branch, as after checking out a tag.
void SetRemote(const wxString &aRemote)
bool SetRemoteUrl(const wxString &aName, const wxString &aUrl)
const std::vector< wxString > & GetErrorStrings() const
wxString GetErrorString()
Read repository state without blocking the UI.
bool IsBusy() const
Remains true until the running worker returns, including after Cancel().
void Cancel()
Suppress the running completion and discard pending work. Does not wait for a read.
void Run(std::shared_ptr< KIGIT_COMMON > aSession, std::function< void(KIGIT_COMMON &)> aWork, std::function< void()> aDone)
Run aWork under the session's action lock on a worker, then aDone on the UI thread.
void SetCancelToken(const KIGIT_COMMON::CANCEL_TOKEN &aToken)
Callbacks read this token rather than the session, so one operation's cancel cannot reach another.
bool IsThisOperationCancelled() const
void SetWaitForActionLock(bool aWait)
Worker transfers may wait for a background read to finish.
bool IsBusy() const
True while a transfer started here is still running.
bool Start(const std::string &aLabel, const std::function< void(const KIGIT_COMMON::CANCEL_TOKEN &)> &aWork, const std::function< void()> &aDone)
Run aWork on a background thread, then aDone on the UI thread.
Give each test a backend and a temporary directory.
static bool empty(const wxTextEntryBase *aCtrl)
static std::string ToStdString(const wxString &aStr)
GIT_WORKTREE_STATUS ReadGitWorktreeStatus(KIGIT_COMMON &aCommon, const wxString &aProjectPath)
Read tracked changes and Git LFS using a captured project path.
std::string message
std::unique_ptr< git_tree, decltype([](git_tree *aTree) { git_tree_free(aTree); })> GitTreePtr
A unique pointer for git_tree objects with automatic cleanup.
void SortBranchesForDisplay(std::vector< KIGIT_BRANCH_INFO > &aBranches)
Put the current branch first, then local and remote branches with the newest tips first.
bool IsPrivateKeyEncrypted(const wxString &aKeyText)
Report whether an SSH private key is protected by a passphrase.
void SplitCommitMessage(const wxString &aMessage, wxString &aSummary, wxString &aDescription)
Split a commit message into its first line and the description after the first blank line.
wxString BranchNameError(const wxString &aName)
Empty when aName is a branch name git would accept, otherwise why it would not, in the words to show ...
std::unique_ptr< git_repository, decltype([](git_repository *aRepo) { git_repository_free(aRepo); })> GitRepositoryPtr
A unique pointer for git_repository objects with automatic cleanup.
GitUserConfig ResolveGitIdentity(const GIT_IDENTITY &aIdentity, const COMMON_SETTINGS::GIT &aPreferences, const wxString &aUserName)
Fill missing values from custom preferences, then use the OS user name if needed.
std::unique_ptr< git_commit, decltype([](git_commit *aCommit) { git_commit_free(aCommit); })> GitCommitPtr
A unique pointer for git_commit objects with automatic cleanup.
GIT_IDENTITY ReadGitIdentity(KIGIT_COMMON &aCommon)
Read on a worker while holding the session's action lock.
std::unique_ptr< git_config, decltype([](git_config *aConfig) { git_config_free(aConfig); })> GitConfigPtr
A unique pointer for git_config objects with automatic cleanup.
std::unique_ptr< git_reference, decltype([](git_reference *aRef) { git_reference_free(aRef); })> GitReferencePtr
A unique pointer for git_reference objects with automatic cleanup.
wxString ComposeCommitMessage(const wxString &aSummary, const wxString &aDescription, const wxString &aSignOff)
Join a summary line, an optional description and an optional sign-off into one commit message.
bool ValidateIdentity(const wxString &aName, const wxString &aEmail, wxString &aError)
Check a name and email for recording commits. False leaves the reason in aError.
std::unique_ptr< git_signature, decltype([](git_signature *aSignature) { git_signature_free(aSignature); })> GitSignaturePtr
A unique pointer for git_signature objects with automatic cleanup.
std::unique_ptr< git_index, decltype([](git_index *aIndex) { git_index_free(aIndex); })> GitIndexPtr
A unique pointer for git_index objects with automatic cleanup.
std::unique_ptr< git_object, decltype([](git_object *aObject) { git_object_free(aObject); })> GitObjectPtr
A unique pointer for git_object objects with automatic cleanup.
std::unique_ptr< git_remote, decltype([](git_remote *aRemote) { git_remote_free(aRemote); })> GitRemotePtr
A unique pointer for git_remote objects with automatic cleanup.
KIGIT::GitRepositoryPtr InitGitRepository(const wxString &aPath, const char *aBranch="main", const char *aAuthorName="QA Test", const char *aAuthorEmail="[email protected]")
Pin the branch and author so repository setup does not depend on the user's Git config.
void WriteGitFile(const wxString &aRepoPath, const wxString &aRelativePath, const std::string &aContents)
Write test data without staging it. Create parent directories when needed.
git_oid CommitGitFile(git_repository *aRepo, const wxString &aPath, const wxString &aMessage)
Stage one file and commit the index. Other staged changes are kept.
Repository state used before an operation that requires a clean working tree.
std::vector< wxString > outsideProject
wxString RefusalMessage(const std::vector< wxString > &aUnsavedEditors, bool aAllowTrackedChanges=false) const
Empty when rewriting is allowed.
Repository values and whether Git can use them to record a commit.
std::optional< KIGIT_AHEAD_BEHIND > divergence
Comparison with upstream, if it could be read.
wxString upstream
The branch this one tracks, or empty.
std::vector< wxString > parents
Short hashes.
std::vector< KIGIT_COMMIT_FILE > files
A transport address with any embedded credentials extracted.
wxString url
Keeps the SSH username, scheme and port, but never a password.
wxString search
Keep only commits whose summary, author or hash contains this.
wxString branch
One branch, local or remote, or empty for every branch and HEAD.
wxString message
The tag's own message, or the commit's for a lightweight tag.
wxString email
Their email.
wxString name
The tag name.
wxString hash
Short hash of the tagged commit.
wxDateTime when
When the tag was made, or the commit for a lightweight tag.
wxString author
Who made the tag, or the commit for a lightweight tag.
bool found
False when no tag of that name points at a commit.
BOOST_AUTO_TEST_CASE(HorizontalAlignment)
BOOST_REQUIRE(intersection.has_value()==c.ExpectedIntersection.has_value())
BOOST_AUTO_TEST_SUITE_END()
std::string path
BOOST_AUTO_TEST_CASE(GitRootDirectoryReturnsWorkdir)
VECTOR3I expected(15, 30, 45)
BOOST_TEST_CONTEXT("Test Clearance")
wxString result
Test unit parsing edge cases and error handling.
BOOST_CHECK_EQUAL(result, "25.4")