• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MerginMaps / geodiff / 28382583657

29 Jun 2026 03:15PM UTC coverage: 87.592% (-0.5%) from 88.134%
28382583657

Pull #254

github

web-flow
Merge 80de3bf91 into bc2b4f0ad
Pull Request #254: Add tables to include

81 of 113 new or added lines in 3 files covered. (71.68%)

3692 of 4215 relevant lines covered (87.59%)

573.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.55
/geodiff/src/geodiff-cli.cpp
1
/*
2
 GEODIFF - MIT License
3
 Copyright (C) 2019 Peter Petrik
4
*/
5

6
#include "geodiff.h"
7
#include <iostream>
8
#include <stdio.h>
9
#include <string.h>
10
#include <string>
11

12
#include <fstream>
13
#include <vector>
14
#include <sstream>
15
#include "geodiffutils.hpp"
16
#include "geodiffcontext.hpp"
17
#include "driver.h"
18

19
static bool fileToStdout( const std::string &filepath )
12✔
20
{
21
#ifdef WIN32
22
  std::ifstream f;
23
  try
24
  {
25
    f.open( stringToWString( filepath ) );
26
  }
27
  catch ( GeoDiffException & )
28
  {
29
    return false;
30
  }
31
#else
32
  std::ifstream f( filepath );
12✔
33
#endif
34
  if ( !f.is_open() )
12✔
35
    return false;
×
36

37
  std::cout << f.rdbuf();
12✔
38
  return true;
12✔
39
}
12✔
40

41
static bool parseRequiredArgument( std::string &value, const std::vector<std::string> &args, size_t &i, const std::string &argName, const std::string &cmdName )
149✔
42
{
43
  if ( i < args.size() )
149✔
44
  {
45
    value = args[i++];
123✔
46
    return true;
123✔
47
  }
48
  else
49
  {
50
    std::cout << "Error: missing " << argName << " for '" << cmdName << "' command." << std::endl;
26✔
51
    return false;
26✔
52
  }
53
}
54

55
static bool checkNoExtraArguments( const std::vector<std::string> &args, size_t i, const std::string &cmdName )
42✔
56
{
57
  if ( i < args.size() )
42✔
58
  {
59
    std::cout << "Error: unexpected extra arguments for '" << cmdName << "' command." << std::endl;
12✔
60
    return false;
12✔
61
  }
62
  return true;
30✔
63
}
64

65
static bool isOption( const std::string &str )
86✔
66
{
67
  return str.size() > 0 && str[0] == '-';
86✔
68
}
69

70
static bool parseDriverOption( const std::vector<std::string> &args, size_t &i, const std::string &cmdName, std::string &driverName, std::string &driverOptions, std::string &tablesToSkip, std::string &tablesToInclude )
43✔
71
{
72
  for ( ; i < args.size(); ++i )
48✔
73
  {
74
    if ( !isOption( args[i] ) )
42✔
75
      break;  // no more options
27✔
76

77
    if ( args[i] == "--driver" )
15✔
78
    {
79
      if ( i + 2 >= args.size() )
10✔
80
      {
81
        std::cout << "Error: missing arguments for driver option" << std::endl;
7✔
82
        return false;
7✔
83
      }
84
      driverName = args[i + 1];
3✔
85
      driverOptions = args[i + 2];
3✔
86
      i += 2;
3✔
87
      continue;
3✔
88
    }
89
    else if ( args[i] == "--skip-tables" )
5✔
90
    {
91
      if ( i + 1 >= args.size() )
3✔
92
      {
93
        std::cout << "Error: missing arguments for skip-tables option" << std::endl;
1✔
94
        return false;
1✔
95
      }
96
      tablesToSkip = args[i + 1];
2✔
97
      i += 1;
2✔
98
      continue;
2✔
99
    }
100
    else if ( args[i] == "--include-tables" )
2✔
101
    {
NEW
102
      if ( i + 1 >= args.size() )
×
103
      {
NEW
104
        std::cout << "Error: missing arguments for include-tables option" << std::endl;
×
NEW
105
        return false;
×
106
      }
NEW
107
      tablesToInclude = args[i + 1];
×
NEW
108
      i += 1;
×
NEW
109
      continue;
×
110
    }
111
    else
112
    {
113
      std::cout << "Error: unknown option '" << args[i] << "' for '" << cmdName << "' command." << std::endl;
2✔
114
      return false;
2✔
115
    }
116
  }
117
  return true;
33✔
118
}
119

120
static std::vector<std::string> parseIgnoredTables( std::string &tablesToSkip )
4✔
121
{
122
  std::vector<std::string> tables;
4✔
123

124
  std::istringstream strm( tablesToSkip );
4✔
125
  std::string s;
4✔
126
  while ( getline( strm, s, ';' ) )
8✔
127
  {
128
    tables.push_back( s );
4✔
129
  }
130

131
  return tables;
8✔
132
}
4✔
133

134

135
static int handleCmdDiff( GEODIFF_ContextH context, const std::vector<std::string> &args )
18✔
136
{
137
  //   geodiff diff [OPTIONS...] DB_1 DB_2 [CH_OUTPUT]
138

139
  bool writeJson = false;
18✔
140
  bool writeSummary = false;
18✔
141
  bool printOutput = true;
18✔
142
  std::string db1, db2, chOutput;
18✔
143
  std::string driver1Name = "sqlite", driver2Name = "sqlite", driver1Options, driver2Options;
72✔
144
  std::string tablesToSkip, tablesToInclude;
18✔
145
  size_t i = 1;
18✔
146

147
  // parse options
148
  for ( ; i < args.size(); ++i )
31✔
149
  {
150
    if ( !isOption( args[i] ) )
30✔
151
      break;  // no more options
12✔
152

153
    if ( args[i] == "--json" )
18✔
154
    {
155
      writeJson = true;
4✔
156
      continue;
4✔
157
    }
158
    else if ( args[i] == "--summary" )
14✔
159
    {
160
      writeSummary = true;
4✔
161
      continue;
4✔
162
    }
163
    else if ( args[i] == "--driver" || args[i] == "--driver-1" || args[i] == "--driver-2" )
10✔
164
    {
165
      if ( i + 2 >= args.size() )
3✔
166
      {
167
        std::cout << "Error: missing arguments for driver option" << std::endl;
×
168
        return 1;
×
169
      }
170
      if ( args[i] == "--driver" || args[i] == "--driver-1" )
3✔
171
      {
172
        driver1Name = args[i + 1];
2✔
173
        driver1Options = args[i + 2];
2✔
174
      }
175
      if ( args[i] == "--driver" || args[i] == "--driver-2" )
3✔
176
      {
177
        driver2Name = args[i + 1];
2✔
178
        driver2Options = args[i + 2];
2✔
179
      }
180
      i += 2;
3✔
181
      continue;
3✔
182
    }
183
    else if ( args[i] == "--skip-tables" )
7✔
184
    {
185
      if ( i + 1 >= args.size() )
3✔
186
      {
187
        std::cout << "Error: missing arguments for skip-tables option" << std::endl;
1✔
188
        return 1;
1✔
189
      }
190
      tablesToSkip = args[i + 1];
2✔
191
      i += 1;
2✔
192
      continue;
2✔
193
    }
194
    else if ( args[i] == "--include-tables" )
4✔
195
    {
NEW
196
      if ( i + 1 >= args.size() )
×
197
      {
NEW
198
        std::cout << "Error: missing arguments for include-tables option" << std::endl;
×
NEW
199
        return 1;
×
200
      }
NEW
201
      tablesToInclude = args[i + 1];
×
NEW
202
      i += 1;
×
NEW
203
      continue;
×
204
    }
205
    else
206
    {
207
      std::cout << "Error: unknown option '" << args[i] << "' for 'diff' command." << std::endl;
4✔
208
      return 1;
4✔
209
    }
210
  }
211

212
  // check validity of options
213
  if ( writeJson && writeSummary )
13✔
214
  {
215
    std::cout << "Error: only one of the options can be passed: --json or --summary" << std::endl;
1✔
216
    return 1;
1✔
217
  }
218

219
  if ( !tablesToSkip.empty() && !tablesToInclude.empty() )
12✔
220
  {
NEW
221
    std::cout << "Error: only one of --skip-tables or --include-tables can be used at a time." << std::endl;
×
NEW
222
    return 1;
×
223
  }
224

225
  Context *ctx = static_cast<Context *>( context );
12✔
226
  if ( !tablesToSkip.empty() )
12✔
227
    ctx->setTablesToSkip( parseIgnoredTables( tablesToSkip ) );
2✔
228
  else if ( !tablesToInclude.empty() )
10✔
NEW
229
    ctx->setTablesToInclude( parseIgnoredTables( tablesToInclude ) );
×
230

231
  // parse required arguments
232
  if ( !parseRequiredArgument( db1, args, i, "DB_1", "diff" ) )
48✔
233
    return 1;
1✔
234
  if ( !parseRequiredArgument( db2, args, i, "DB_2", "diff" ) )
44✔
235
    return 1;
2✔
236

237
  if ( i < args.size() )
9✔
238
  {
239
    // optional output argument
240
    printOutput = false;
2✔
241
    chOutput = args[i++];
2✔
242

243
    if ( !checkNoExtraArguments( args, i, "diff" ) )
4✔
244
      return 1;
1✔
245
  }
246

247
  std::string changeset;
8✔
248
  TmpFile tmpChangeset;
8✔
249
  if ( printOutput || writeJson || writeSummary )
8✔
250
  {
251
    changeset = randomTmpFilename( );
7✔
252
    tmpChangeset.setPath( changeset );
7✔
253
  }
254
  else
255
    changeset = chOutput;
1✔
256

257
  if ( driver1Name == driver2Name && driver1Options == driver2Options )
8✔
258
  {
259
    int ret = GEODIFF_createChangesetEx( context,
8✔
260
                                         driver1Name.data(), driver1Options.data(),
8✔
261
                                         db1.data(), db2.data(), changeset.data() );
8✔
262
    if ( ret != GEODIFF_SUCCESS )
8✔
263
    {
264
      std::cout << "Error: diff failed!" << std::endl;
1✔
265
      return 1;
1✔
266
    }
267
  }
268
  else
269
  {
270
    int ret = GEODIFF_createChangesetDr( context,
×
271
                                         driver1Name.data(), driver1Options.data(), db1.data(),
×
272
                                         driver2Name.data(), driver2Options.data(), db2.data(),
×
273
                                         changeset.data() );
×
274
    if ( ret != GEODIFF_SUCCESS )
×
275
    {
276
      std::cout << "Error: diff failed!" << std::endl;
×
277
      return 1;
×
278
    }
279
  }
280

281
  if ( writeJson || writeSummary )
7✔
282
  {
283
    std::string json;
5✔
284
    TmpFile tmpJson;
5✔
285
    if ( printOutput )
5✔
286
    {
287
      json = randomTmpFilename( );
5✔
288
      tmpJson.setPath( json );
5✔
289
    }
290
    else
291
      json = chOutput;
×
292

293
    if ( writeJson )
5✔
294
    {
295
      if ( GEODIFF_listChanges( context, changeset.data(), json.data() ) != GEODIFF_SUCCESS )
2✔
296
      {
297
        std::cout << "Error: failed to convert changeset to JSON!" << std::endl;
×
298
        return 1;
×
299
      }
300
    }
301
    else  // writeSummary
302
    {
303
      if ( GEODIFF_listChangesSummary( context, changeset.data(), json.data() ) != GEODIFF_SUCCESS )
3✔
304
      {
305
        std::cout << "Error: failed to convert changeset to summary!" << std::endl;
×
306
        return 1;
×
307
      }
308
    }
309

310
    if ( printOutput )
5✔
311
    {
312
      if ( !fileToStdout( json ) )
5✔
313
      {
314
        std::cout << "Error: unable to read content of file " << json << std::endl;
×
315
        return 1;
×
316
      }
317
    }
318
  }
10✔
319
  else if ( printOutput )
2✔
320
  {
321
    if ( !fileToStdout( changeset ) )
1✔
322
    {
323
      std::cout << "Error: unable to read content of file " << changeset << std::endl;
×
324
      return 1;
×
325
    }
326
  }
327

328
  return 0;
7✔
329
}
18✔
330

331

332
static int handleCmdApply( GEODIFF_ContextH context, const std::vector<std::string> &args )
13✔
333
{
334
  // geodiff apply [OPTIONS...] DB CH_INPUT
335

336
  size_t i = 1;
13✔
337
  std::string db, changeset;
13✔
338
  std::string driverName = "sqlite", driverOptions;
26✔
339
  std::string tablesToSkip, tablesToInclude;
13✔
340

341
  // parse options
342
  if ( !parseDriverOption( args, i, "apply", driverName, driverOptions, tablesToSkip, tablesToInclude ) )
26✔
343
    return 1;
3✔
344

345
  // parse required arguments
346
  if ( !parseRequiredArgument( db, args, i, "DB", "apply" ) )
40✔
347
    return 1;
2✔
348
  if ( !parseRequiredArgument( changeset, args, i, "CH_INPUT", "apply" ) )
32✔
349
    return 1;
3✔
350
  if ( !checkNoExtraArguments( args, i, "apply" ) )
10✔
351
    return 1;
2✔
352

353
  Context *ctx = static_cast<Context *>( context );
3✔
354
  if ( !tablesToSkip.empty() )
3✔
355
    ctx->setTablesToSkip( parseIgnoredTables( tablesToSkip ) );
1✔
356
  else if ( !tablesToInclude.empty() )
2✔
NEW
357
    ctx->setTablesToInclude( parseIgnoredTables( tablesToInclude ) );
×
358

359
  int ret = GEODIFF_applyChangesetEx( context, driverName.data(), driverOptions.data(), db.data(), changeset.data() );
3✔
360
  if ( ret != GEODIFF_SUCCESS )
3✔
361
  {
362
    std::cout << "Error: apply changeset failed!" << std::endl;
1✔
363
    return 1;
1✔
364
  }
365

366
  return 0;
2✔
367
}
13✔
368

369
static int handleCmdRebaseDiff( GEODIFF_ContextH context,  const std::vector<std::string> &args )
10✔
370
{
371
  // geodiff rebase-diff [OPTIONS...] DB_BASE CH_BASE_OUR CH_BASE_THEIR CH_REBASED CONFLICT
372

373
  size_t i = 1;
10✔
374
  std::string dbBase, chBaseOur, chBaseTheir, chRebased, conflict;
10✔
375
  std::string driverName = "sqlite", driverOptions;
20✔
376
  std::string tablesToSkip, tablesToInclude;
10✔
377

378
  // parse options
379
  if ( !parseDriverOption( args, i, "rebase-diff", driverName, driverOptions, tablesToSkip, tablesToInclude ) )
20✔
380
    return 1;
2✔
381

382
  if ( !parseRequiredArgument( dbBase, args, i, "DB_BASE", "rebase-diff" ) )
32✔
383
    return 1;
1✔
384
  if ( !parseRequiredArgument( chBaseOur, args, i, "CH_BASE_OUR", "rebase-diff" ) )
28✔
385
    return 1;
1✔
386
  if ( !parseRequiredArgument( chBaseTheir, args, i, "CH_BASE_THEIR", "rebase-diff" ) )
24✔
387
    return 1;
1✔
388
  if ( !parseRequiredArgument( chRebased, args, i, "CH_REBASED", "rebase-diff" ) )
20✔
389
    return 1;
×
390
  if ( !parseRequiredArgument( conflict, args, i, "CONFLICT", "rebase-diff" ) )
20✔
391
    return 1;
1✔
392
  if ( !checkNoExtraArguments( args, i, "rebase-diff" ) )
8✔
393
    return 1;
1✔
394

395
  Context *ctx = static_cast<Context *>( context );
3✔
396
  if ( !tablesToSkip.empty() )
3✔
NEW
397
    ctx->setTablesToSkip( parseIgnoredTables( tablesToSkip ) );
×
398
  else if ( !tablesToInclude.empty() )
3✔
NEW
399
    ctx->setTablesToInclude( parseIgnoredTables( tablesToInclude ) );
×
400

401
  int ret = GEODIFF_createRebasedChangesetEx(
3✔
402
              context,
403
              driverName.data(), driverOptions.data(),
3✔
404
              dbBase.data(), chBaseOur.data(),
3✔
405
              chBaseTheir.data(), chRebased.data(), conflict.data() );
3✔
406
  if ( ret != GEODIFF_SUCCESS )
3✔
407
  {
408
    std::cout << "Error: rebase-diff failed!" << std::endl;
2✔
409
    return 1;
2✔
410
  }
411

412
  return 0;
1✔
413
}
10✔
414

415
static int handleCmdRebaseDb( GEODIFF_ContextH context, const std::vector<std::string> &args )
9✔
416
{
417
  // geodiff rebase-db [OPTIONS...] DB_BASE DB_OUR CH_BASE_THEIR CONFLICT
418

419
  size_t i = 1;
9✔
420
  std::string dbBase, dbOur, chBaseTheir, conflict;
9✔
421
  std::string driverName = "sqlite", driverOptions;
18✔
422
  std::string tablesToSkip, tablesToInclude;
9✔
423

424
  // parse options
425
  if ( !parseDriverOption( args, i, "rebase-db", driverName, driverOptions, tablesToSkip, tablesToInclude ) )
18✔
426
    return 1;
3✔
427

428
  if ( !parseRequiredArgument( dbBase, args, i, "DB_BASE", "rebase-db" ) )
24✔
429
    return 1;
1✔
430
  if ( !parseRequiredArgument( dbOur, args, i, "DB_OUR", "rebase-db" ) )
20✔
431
    return 1;
×
432
  if ( !parseRequiredArgument( chBaseTheir, args, i, "CH_BASE_THEIR", "rebase-db" ) )
20✔
433
    return 1;
×
434
  if ( !parseRequiredArgument( conflict, args, i, "CONFLICT", "rebase-db" ) )
20✔
435
    return 1;
1✔
436
  if ( !checkNoExtraArguments( args, i, "rebase-db" ) )
8✔
437
    return 1;
×
438

439
  Context *ctx = static_cast<Context *>( context );
4✔
440
  if ( !tablesToSkip.empty() )
4✔
NEW
441
    ctx->setTablesToSkip( parseIgnoredTables( tablesToSkip ) );
×
442
  else if ( !tablesToInclude.empty() )
4✔
NEW
443
    ctx->setTablesToInclude( parseIgnoredTables( tablesToInclude ) );
×
444

445
  int ret = GEODIFF_rebaseEx( context,
4✔
446
                              driverName.data(), driverOptions.data(), dbBase.data(), dbOur.data(),
4✔
447
                              chBaseTheir.data(), conflict.data() );
4✔
448
  if ( ret != GEODIFF_SUCCESS )
4✔
449
  {
450
    std::cout << "Error: rebase-db failed!" << std::endl;
3✔
451
    return 1;
3✔
452
  }
453

454
  return 0;
1✔
455
}
9✔
456

457
static int handleCmdInvert( GEODIFF_ContextH context, const std::vector<std::string> &args )
6✔
458
{
459
  // geodiff invert CH_INPUT CH_OUTPUT
460

461
  size_t i = 1;
6✔
462
  std::string chInput, chOutput;
6✔
463

464
  if ( !parseRequiredArgument( chInput, args, i, "CH_INPUT", "invert" ) )
24✔
465
    return 1;
1✔
466
  if ( !parseRequiredArgument( chOutput, args, i, "CH_OUTPUT", "invert" ) )
20✔
467
    return 1;
2✔
468
  if ( !checkNoExtraArguments( args, i, "invert" ) )
6✔
469
    return 1;
1✔
470

471
  int ret = GEODIFF_invertChangeset( context, chInput.data(), chOutput.data() );
2✔
472
  if ( ret != GEODIFF_SUCCESS )
2✔
473
  {
474
    std::cout << "Error: invert changeset failed!" << std::endl;
1✔
475
    return 1;
1✔
476
  }
477

478
  return 0;
1✔
479
}
6✔
480

481
static int handleCmdConcat( GEODIFF_ContextH context, const std::vector<std::string> &args )
4✔
482
{
483
  // geodiff concat CH_INPUT_1 CH_INPUT_2 [...] CH_OUTPUT
484

485
  if ( args.size() < 4 )
4✔
486
  {
487
    std::cout << "Error: 'concat' command needs at least two input changesets and one output changeset." << std::endl;
1✔
488
    return 1;
1✔
489
  }
490

491
  std::string chOutput = args[args.size() - 1];
3✔
492

493
  std::vector<const char *> changesets;
3✔
494
  for ( size_t i = 1; i < args.size() - 1; ++i )
10✔
495
  {
496
    changesets.push_back( args[i].data() );
7✔
497
  }
498

499
  int ret = GEODIFF_concatChanges( context, ( int ) changesets.size(), changesets.data(), chOutput.data() );
3✔
500
  if ( ret != GEODIFF_SUCCESS )
3✔
501
  {
502
    std::cout << "Error: concat changesets failed!" << std::endl;
1✔
503
    return 1;
1✔
504
  }
505

506
  return 0;
2✔
507
}
3✔
508

509
static int handleCmdAsJson( GEODIFF_ContextH context, const std::vector<std::string> &args )
8✔
510
{
511
  // geodiff as-json CH_INPUT [CH_OUTPUT]
512

513
  size_t i = 1;
8✔
514
  bool printOutput = true;
8✔
515
  std::string chInput, chOutput;
8✔
516

517
  // parse required arguments
518
  if ( !parseRequiredArgument( chInput, args, i, "CH_INPUT", "as-json" ) )
32✔
519
    return 1;
1✔
520

521
  if ( i < args.size() )
7✔
522
  {
523
    // optional output argument
524
    printOutput = false;
3✔
525
    chOutput = args[i++];
3✔
526

527
    if ( !checkNoExtraArguments( args, i, "as-json" ) )
6✔
528
      return 1;
1✔
529
  }
530

531
  std::string changeset;
6✔
532
  TmpFile tmpChangeset;
6✔
533
  if ( printOutput )
6✔
534
  {
535
    changeset = randomTmpFilename( );
4✔
536
    tmpChangeset.setPath( changeset );
4✔
537
  }
538
  else
539
    changeset = chOutput;
2✔
540

541
  int ret = GEODIFF_listChanges( context, chInput.data(), changeset.data() );
6✔
542
  if ( ret != GEODIFF_SUCCESS )
6✔
543
  {
544
    std::cout << "Error: export changeset to JSON failed!" << std::endl;
1✔
545
    return 1;
1✔
546
  }
547

548
  if ( printOutput )
5✔
549
  {
550
    if ( !fileToStdout( changeset ) )
4✔
551
    {
552
      std::cout << "Error: unable to read content of file " << changeset << std::endl;
×
553
      return 1;
×
554
    }
555
  }
556

557
  return 0;
5✔
558
}
8✔
559

560
static int handleCmdAsSummary( GEODIFF_ContextH context, const std::vector<std::string> &args )
4✔
561
{
562
  // geodiff as-summary CH_INPUT [SUMMARY]
563

564
  size_t i = 1;
4✔
565
  bool printOutput = true;
4✔
566
  std::string chInput, chOutput;
4✔
567

568
  // parse required arguments
569
  if ( !parseRequiredArgument( chInput, args, i, "CH_INPUT", "as-summary" ) )
16✔
570
    return 1;
1✔
571

572
  if ( i < args.size() )
3✔
573
  {
574
    // optional output argument
575
    printOutput = false;
2✔
576
    chOutput = args[i++];
2✔
577

578
    if ( !checkNoExtraArguments( args, i, "as-summary" ) )
4✔
579
      return 1;
×
580
  }
581

582
  std::string summary;
3✔
583
  TmpFile tmpSummary;
3✔
584
  if ( printOutput )
3✔
585
  {
586
    summary = randomTmpFilename( );
1✔
587
    tmpSummary.setPath( summary );
1✔
588
  }
589
  else
590
    summary = chOutput;
2✔
591

592
  int ret = GEODIFF_listChangesSummary( context, chInput.data(), summary.data() );
3✔
593
  if ( ret != GEODIFF_SUCCESS )
3✔
594
  {
595
    std::cout << "Error: export changeset to summary failed!" << std::endl;
1✔
596
    return 1;
1✔
597
  }
598

599
  if ( printOutput )
2✔
600
  {
601
    if ( !fileToStdout( summary ) )
1✔
602
    {
603
      std::cout << "Error: unable to read content of file " << summary << std::endl;
×
604
      return 1;
×
605
    }
606
  }
607

608
  return 0;
2✔
609
}
4✔
610

611
static int handleCmdCopy( GEODIFF_ContextH context, const std::vector<std::string> &args )
12✔
612
{
613
  // geodiff copy [OPTIONS...] DB_SOURCE DB_DESTINATION
614

615
  size_t i = 1;
12✔
616
  std::string chInput, chOutput;
12✔
617
  std::string driver1Name = "sqlite", driver1Options, driver2Name = "sqlite", driver2Options;
48✔
618
  std::string tablesToSkip, tablesToInclude;
12✔
619

620
  // parse options
621
  for ( ; i < args.size(); ++i )
17✔
622
  {
623
    if ( !isOption( args[i] ) )
14✔
624
      break;  // no more options
9✔
625

626
    if ( args[i] == "--driver" || args[i] == "--driver-1" || args[i] == "--driver-2" )
5✔
627
    {
628
      if ( i + 2 >= args.size() )
3✔
629
      {
630
        std::cout << "Error: missing arguments for driver option" << std::endl;
×
631
        return 1;
×
632
      }
633
      if ( args[i] == "--driver" || args[i] == "--driver-1" )
3✔
634
      {
635
        driver1Name = args[i + 1];
2✔
636
        driver1Options = args[i + 2];
2✔
637
      }
638
      if ( args[i] == "--driver" || args[i] == "--driver-2" )
3✔
639
      {
640
        driver2Name = args[i + 1];
2✔
641
        driver2Options = args[i + 2];
2✔
642
      }
643
      i += 2;
3✔
644
      continue;
3✔
645
    }
646
    else if ( args[i] == "--skip-tables" )
2✔
647
    {
648
      if ( i + 1 >= args.size() )
2✔
649
      {
650
        std::cout << "Error: missing arguments for skip-tables option" << std::endl;
×
651
        return 1;
×
652
      }
653
      tablesToSkip = args[i + 1];
2✔
654
      i += 1;
2✔
655
      continue;
2✔
656
    }
NEW
657
    else if ( args[i] == "--include-tables" )
×
658
    {
NEW
659
      if ( i + 1 >= args.size() )
×
660
      {
NEW
661
        std::cout << "Error: missing arguments for include-tables option" << std::endl;
×
NEW
662
        return 1;
×
663
      }
NEW
664
      tablesToInclude = args[i + 1];
×
NEW
665
      i += 1;
×
NEW
666
      continue;
×
667
    }
668
    else
669
    {
670
      std::cout << "Error: unknown option '" << args[i] << "' for 'copy' command." << std::endl;
×
671
      return 1;
×
672
    }
673
  }
674

675
  if ( !parseRequiredArgument( chInput, args, i, "DB_SOURCE", "copy" ) )
48✔
676
    return 1;
3✔
677
  if ( !parseRequiredArgument( chOutput, args, i, "DB_DESTINATION", "copy" ) )
36✔
678
    return 1;
1✔
679
  if ( !checkNoExtraArguments( args, i, "copy" ) )
16✔
680
    return 1;
1✔
681

682
  Context *ctx = static_cast<Context *>( context );
7✔
683
  if ( !tablesToSkip.empty() )
7✔
684
    ctx->setTablesToSkip( parseIgnoredTables( tablesToSkip ) );
1✔
685
  else if ( !tablesToInclude.empty() )
6✔
NEW
686
    ctx->setTablesToInclude( parseIgnoredTables( tablesToInclude ) );
×
687

688
  if ( driver1Name == "sqlite" && driver2Name == "sqlite" )
7✔
689
  {
690
    if ( !tablesToSkip.empty() || !tablesToInclude.empty() )
7✔
691
    {
692
      std::cout << "Source and destination drivers are \"sqlite\". Table filter options will be ignored." << std::endl;
1✔
693
      return 1;
1✔
694
    }
695

696
    int ret = GEODIFF_makeCopySqlite( context, chInput.data(), chOutput.data() );
6✔
697
    if ( ret != GEODIFF_SUCCESS )
6✔
698
    {
699
      std::cout << "Error: copy failed!" << std::endl;
1✔
700
      return 1;
1✔
701
    }
702
  }
703
  else
704
  {
705
    int ret = GEODIFF_makeCopy( context,
×
706
                                driver1Name.data(), driver1Options.data(), chInput.data(),
×
707
                                driver2Name.data(), driver2Options.data(), chOutput.data() );
×
708
    if ( ret != GEODIFF_SUCCESS )
×
709
    {
710
      std::cout << "Error: copy failed!" << std::endl;
×
711
      return 1;
×
712
    }
713
  }
714

715
  return 0;
5✔
716
}
12✔
717

718
static int handleCmdSchema( GEODIFF_ContextH context, const std::vector<std::string> &args )
5✔
719
{
720
  // geodiff schema [OPTIONS...] DB [SCHEMA_JSON]
721

722
  size_t i = 1;
5✔
723
  bool printOutput = true;
5✔
724
  std::string db, schemaJson;
5✔
725
  std::string driverName = "sqlite", driverOptions;
10✔
726
  std::string tablesToSkip, tablesToInclude;
5✔
727

728
  // parse options
729
  if ( !parseDriverOption( args, i, "schema", driverName, driverOptions, tablesToSkip, tablesToInclude ) )
10✔
730
    return 1;
×
731

732
  // parse required arguments
733
  if ( !parseRequiredArgument( db, args, i, "DB", "schema" ) )
20✔
734
    return 1;
1✔
735

736
  if ( i < args.size() )
4✔
737
  {
738
    // optional output argument
739
    printOutput = false;
2✔
740
    schemaJson = args[i++];
2✔
741

742
    if ( !checkNoExtraArguments( args, i, "schema" ) )
4✔
743
      return 1;
1✔
744
  }
745

746
  Context *ctx = static_cast<Context *>( context );
3✔
747
  if ( !tablesToSkip.empty() )
3✔
NEW
748
    ctx->setTablesToSkip( parseIgnoredTables( tablesToSkip ) );
×
749
  else if ( !tablesToInclude.empty() )
3✔
NEW
750
    ctx->setTablesToInclude( parseIgnoredTables( tablesToInclude ) );
×
751

752
  std::string json;
3✔
753
  TmpFile tmpJson;
3✔
754
  if ( printOutput )
3✔
755
  {
756
    json = randomTmpFilename( );
2✔
757
    tmpJson.setPath( json );
2✔
758
  }
759
  else
760
    json = schemaJson;
1✔
761

762
  int ret = GEODIFF_schema( context, driverName.data(), driverOptions.data(), db.data(), json.data() );
3✔
763
  if ( ret != GEODIFF_SUCCESS )
3✔
764
  {
765
    std::cout << "Error: export changeset to summary failed!" << std::endl;
1✔
766
    return 1;
1✔
767
  }
768

769
  if ( printOutput )
2✔
770
  {
771
    if ( !fileToStdout( json ) )
1✔
772
    {
773
      std::cout << "Error: unable to read content of file " << json << std::endl;
×
774
      return 1;
×
775
    }
776
  }
777

778
  return 0;
2✔
779
}
5✔
780

781
static int handleCmdDump( GEODIFF_ContextH context, const std::vector<std::string> &args )
6✔
782
{
783
  // geodiff dump [OPTIONS...] DB CH_OUTPUT
784

785
  size_t i = 1;
6✔
786
  std::string db, chOutput;
6✔
787
  std::string driverName = "sqlite", driverOptions;
12✔
788
  std::string tablesToSkip, tablesToInclude;
6✔
789

790
  // parse options
791
  if ( !parseDriverOption( args, i, "dump", driverName, driverOptions, tablesToSkip, tablesToInclude ) )
12✔
792
    return 1;
2✔
793

794
  if ( !parseRequiredArgument( db, args, i, "DB", "dump" ) )
16✔
795
    return 1;
1✔
796
  if ( !parseRequiredArgument( chOutput, args, i, "CH_OUTPUT", "dump" ) )
12✔
797
    return 1;
1✔
798
  if ( !checkNoExtraArguments( args, i, "dump" ) )
4✔
799
    return 1;
1✔
800

801
  Context *ctx = static_cast<Context *>( context );
1✔
802
  if ( !tablesToSkip.empty() )
1✔
NEW
803
    ctx->setTablesToSkip( parseIgnoredTables( tablesToSkip ) );
×
804
  else if ( !tablesToInclude.empty() )
1✔
NEW
805
    ctx->setTablesToInclude( parseIgnoredTables( tablesToInclude ) );
×
806

807
  int ret = GEODIFF_dumpData( context, driverName.data(), driverOptions.data(), db.data(), chOutput.data() );
1✔
808
  if ( ret != GEODIFF_SUCCESS )
1✔
809
  {
810
    std::cout << "Error: dump database failed!" << std::endl;
×
811
    return 1;
×
812
  }
813

814
  return 0;
1✔
815
}
6✔
816

817
static int handleCmdDrivers( const std::vector<std::string> &args )
2✔
818
{
819
  // geodiff drivers
820

821
  size_t i = 1;
2✔
822
  if ( !checkNoExtraArguments( args, i, "drivers" ) )
4✔
823
    return 1;
1✔
824

825
  for ( const std::string &driverName : Driver::drivers() )
3✔
826
  {
827
    std::cout << driverName << std::endl;
2✔
828
  }
1✔
829

830
  return 0;
1✔
831
}
832

833
static int handleCmdVersion( const std::vector<std::string> &args )
3✔
834
{
835
  size_t i = 1;
3✔
836
  if ( !checkNoExtraArguments( args, i, "version" ) )
6✔
837
    return 1;
1✔
838

839
  std::cout << GEODIFF_version() << std::endl;
2✔
840
  return 0;
2✔
841
}
842

843
static GEODIFF_LoggerLevel getGeodiffLoggerLevel( )
104✔
844
{
845
  return ( GEODIFF_LoggerLevel ) getEnvVarInt( "GEODIFF_LOGGER_LEVEL", ( int ) LevelWarning );
208✔
846
}
847

848
static int handleCmdHelp( const std::vector<std::string> &args )
2✔
849
{
850
  size_t i = 1;
2✔
851
  if ( !checkNoExtraArguments( args, i, "help" ) )
4✔
852
    return 1;
1✔
853

854

855
  std::cout << "GEODIFF " << GEODIFF_version() << ", a tool for handling diffs for geospatial data.\n\
1✔
856
\n\
857
Usage: geodiff <command> [args...]\n\
858
\n\
859
You can control verbosity using the environment variable GEODIFF_LOGGER_LEVEL:\n\
860
    0 = Nothing, 1 = Errors, 2 = Warnings, 3 = Info, 4 = Debug\n\
861
    (The default is 2 - showing only errors and warnings.)\n\
862
\n\
863
In the commands listed below, database files may be any GeoPackage files or other\n\
864
kinds of SQLite database files. This is using the default 'sqlite' driver. Even\n\
865
when 'sqlite' driver is specified in a command with --driver option, there are\n\
866
no extra driver options it needs (empty string \"\" can be passed).\n\
867
\n\
868
There may be other drivers available, for example 'postgres' driver. Its driver\n\
869
options expect the connection string as understood by its client library - either\n\
870
key/value pairs (e.g. \"host=localhost port=5432 dbname=mydb\") or connection URI\n\
871
(e.g. \"postgresql://localhost:5432/mydb\").\n\
872
\n\
873
Create and apply changesets (diffs):\n\
874
\n\
875
  geodiff diff [OPTIONS...] DB_1 DB_2 [CH_OUTPUT]\n\
876
\n\
877
    Creates a changeset (diff) between databases DB_BASE and DB_MODIFIED. If CH_OUTPUT\n\
878
    is specified, the result is written to that file, otherwise the output goes\n\
879
    to the standard output. By default, the changeset is written in the binary\n\
880
    format.\n\
881
\n\
882
    Options:\n\
883
      --json          Write changeset in JSON format instead of binary\n\
884
      --summary       Write only a summary for each table (in JSON)\n\
885
      --driver NAME DRIVER_OPTIONS\n\
886
                      Use driver NAME instead of the default 'sqlite' for both\n\
887
                      databases. Driver-specific options are provided in CONN_OPTIONS.\n\
888
      --driver-1 NAME DRIVER_OPTIONS\n\
889
                      Use driver NAME just for the first database. This allows creation\n\
890
                      of changesets across datasets in two different drivers.\n\
891
      --driver-2 NAME DRIVER_OPTIONS\n\
892
                      Use driver NAME just for the second database. This allows\n\
893
                      creation of changesets across datasets in two different drivers.\n\
894
      --skip-tables TABLES\n\
895
                      Ignore specified tables when creating a changeset. Tables are defined as\n\
896
                      a semicolon separated list of names. Cannot be used with --include-tables.\n\
897
      --include-tables TABLES\n\
898
                      Only include specified tables when creating a changeset. Tables are defined\n\
899
                      as a semicolon separated list of names. Cannot be used with --skip-tables.\n\
900
\n\
901
  geodiff apply [OPTIONS...] DB CH_INPUT\n\
902
\n\
903
    Applies a changeset (diff) from file CH_INPUT to the database file DB.\n\
904
    The changeset must be in the binary format (JSON format is not supported).\n\
905
\n\
906
    Options:\n\
907
      --driver NAME DRIVER_OPTIONS\n\
908
                      Use driver NAME instead of the default 'sqlite' for the\n\
909
                      database. Driver-specific options are provided in CONN_OPTIONS.\n\
910
      --skip-tables TABLES\n\
911
                      Ignore specified tables when applying a changeset. Tables are defined as\n\
912
                      a semicolon separated list of names. Cannot be used with --include-tables.\n\
913
      --include-tables TABLES\n\
914
                      Only include specified tables when applying a changeset. Tables are defined\n\
915
                      as a semicolon separated list of names. Cannot be used with --skip-tables.\n\
916
\n\
917
Rebasing:\n\
918
\n\
919
  geodiff rebase-diff [OPTIONS...] DB_BASE CH_BASE_OUR CH_BASE_THEIR CH_REBASED CONFLICT\n\
920
\n\
921
    Creates a rebased changeset. Using DB_BASE as the common base for \"our\" local\n\
922
    changes (CH_BASE_OUR) and \"their\" changes (CH_BASE_THEIR), the command will take\n\
923
    \"our\" changes and rebase them on top of \"their\" changes, and write results\n\
924
    to CH_REBASED file (containing just \"our\" changes, but modified to apply cleanly\n\
925
    on top of \"their\" changes). As a result, taking DB_BASE, applying CH_BASE_THEIR\n\
926
    and then applying CH_REBASED will result in a database containing both \"our\" and\n\
927
    \"their\" changes. If there were any conflicts during the rebase, they will be\n\
928
    written to CONFLICT file (in JSON format).\n\
929
\n\
930
    Options:\n\
931
      --driver NAME DRIVER_OPTIONS\n\
932
                      Use driver NAME instead of the default 'sqlite' for both\n\
933
                      databases. Driver-specific options are provided in CONN_OPTIONS.\n\
934
      --skip-tables TABLES\n\
935
                      Ignore specified tables when creating a rebased changeset. Tables are\n\
936
                      defined as a semicolon separated list of names. Cannot be used with --include-tables.\n\
937
      --include-tables TABLES\n\
938
                      Only include specified tables when creating a rebased changeset. Tables are\n\
939
                      defined as a semicolon separated list of names. Cannot be used with --skip-tables.\n\
940
\n\
941
  geodiff rebase-db [OPTIONS...] DB_BASE DB_OUR CH_BASE_THEIR CONFLICT\n\
942
\n\
943
    Rebases database DB_OUR, using DB_BASE as the common base and CH_BASE_THEIR as the other\n\
944
    source of changes. CH_BASE_THEIR is a changeset containing changes between DB_BASE and\n\
945
    some other database. This will cause DB_OUR to be updated in-place to contain changes\n\
946
    (DB_OUR - DB_BASE) rebased on top of CH_BASE_THEIR. After successful rebase, DB_OUR will\n\
947
    contain both \"our\" and \"their\" changes. If there were any conflicts during\n\
948
    the rebase, they will be written to CONFLICT file (in JSON format).\n\
949
\n\
950
    Options:\n\
951
      --driver NAME DRIVER_OPTIONS\n\
952
                      Use driver NAME instead of the default 'sqlite' for all three\n\
953
                      databases. Driver-specific options are provided in CONN_OPTIONS.\n\
954
      --skip-tables TABLES\n\
955
                      Ignore specified tables when rebasing. Tables are defined as\n\
956
                      a semicolon separated list of names. Cannot be used with --include-tables.\n\
957
      --include-tables TABLES\n\
958
                      Only include specified tables when rebasing. Tables are defined as\n\
959
                      a semicolon separated list of names. Cannot be used with --skip-tables.\n\
960
\n\
961
Utilities:\n\
962
\n\
963
  geodiff invert CH_INPUT CH_OUTPUT\n\
964
\n\
965
    Inverts changeset in file CH_INPUT and writes inverted changeset to CH_OUTPUT.\n\
966
    Both input and output changesets are in the binary format.\n\
967
\n\
968
  geodiff concat CH_INPUT_1 CH_INPUT_2 [...] CH_OUTPUT\n\
969
\n\
970
    Concatenates two or more changeset files (CH_INPUT_1, CH_INPUT_2, ...) into a\n\
971
    single changeset. During concatenation, commands that act on the same rows get\n\
972
    merged together.\n\
973
\n\
974
  geodiff as-json CH_INPUT [CH_OUTPUT]\n\
975
\n\
976
    Converts the changeset in CH_INPUT file (in binary format) to JSON representation.\n\
977
    If CH_OUTPUT file is provided, it will be written to that file, otherwise it will\n\
978
    be written to the standard output.\n\
979
\n\
980
  geodiff as-summary CH_INPUT [SUMMARY]\n\
981
\n\
982
    Converts the changeset in CH_INPUT file (in binary format) to a summary JSON\n\
983
    which only contains overall counts of insert/update/delete commands for each table.\n\
984
\n\
985
  geodiff copy [OPTIONS...] DB_SOURCE DB_DESTINATION\n\
986
\n\
987
    Copies the source database DB_SOURCE to the destination database DB_DESTINATION.\n\
988
\n\
989
    Options:\n\
990
      --driver NAME DRIVER_OPTIONS\n\
991
                      Use driver NAME instead of the default 'sqlite' for both\n\
992
                      databases. Driver-specific options are provided in CONN_OPTIONS.\n\
993
      --driver-1 NAME DRIVER_OPTIONS\n\
994
                      Use driver NAME just for the first database. This allows creation\n\
995
                      of changesets across datasets in two different drivers.\n\
996
      --driver-2 NAME DRIVER_OPTIONS\n\
997
                      Use driver NAME just for the second database. This allows\n\
998
                      creation of changesets across datasets in two different drivers.\n\
999
      --skip-tables TABLES\n\
1000
                      Ignore specified tables when copying the database. Tables are defined\n\
1001
                      as a semicolon separated list of names. Cannot be used with --include-tables.\n\
1002
      --include-tables TABLES\n\
1003
                      Only include specified tables when copying the database. Tables are defined\n\
1004
                      as a semicolon separated list of names. Cannot be used with --skip-tables.\n\
1005
\n\
1006
  geodiff schema [OPTIONS...] DB [SCHEMA_JSON]\n\
1007
\n\
1008
    Writes database schema of DB as understood by geodiff as JSON. If SCHEMA_JSON file\n\
1009
    is provided, the output will be written to the file, otherwise the standard output\n\
1010
    will be used.\n\
1011
\n\
1012
    Options:\n\
1013
      --driver NAME DRIVER_OPTIONS\n\
1014
                      Use driver NAME instead of the default 'sqlite' for the\n\
1015
                      database. Driver-specific options are provided in CONN_OPTIONS.\n\
1016
      --skip-tables TABLES\n\
1017
                      Ignore specified tables when writing a schema. Tables are defined\n\
1018
                      as a semicolon separated list of names. Cannot be used with --include-tables.\n\
1019
      --include-tables TABLES\n\
1020
                      Only include specified tables when writing a schema. Tables are defined\n\
1021
                      as a semicolon separated list of names. Cannot be used with --skip-tables.\n\
1022
\n\
1023
  geodiff dump [OPTIONS...] DB CH_OUTPUT\n\
1024
\n\
1025
    Dumps content of database DB to a changeset as a series of \"insert\" commands.\n\
1026
\n\
1027
    Options:\n\
1028
      --driver NAME DRIVER_OPTIONS\n\
1029
                      Use driver NAME instead of the default 'sqlite' for the\n\
1030
                      database. Driver-specific options are provided in CONN_OPTIONS.\n\
1031
      --skip-tables TABLES\n\
1032
                      Ignore specified tables when dumping the database content. Tables\n\
1033
                      are defined as a semicolon separated list of names. Cannot be used with --include-tables.\n\
1034
      --include-tables TABLES\n\
1035
                      Only include specified tables when dumping the database content. Tables\n\
1036
                      are defined as a semicolon separated list of names. Cannot be used with --skip-tables.\n\
1037
\n\
1038
  geodiff drivers\n\
1039
\n\
1040
    Prints the list of all drivers supported in this version. The \"sqlite\" driver\n\
1041
    is always available.\n\
1042
\n\
1043
  geodiff version\n\
1044
\n\
1045
    Prints version of geodiff.\n\
1046
\n\
1047
  geodiff help\n\
1048
\n\
1049
    Prints this help information.\n\
1050
\n\
1051
Copyright (C) 2019-2023 Lutra Consulting\n\
1052
";
1053
  return 0;
1✔
1054
}
1055

1056
class GeodiffContext
1057
{
1058
  public:
1059
    GeodiffContext()
104✔
1060
    {
104✔
1061
      mContext = GEODIFF_createContext();
104✔
1062
      GEODIFF_CX_setMaximumLoggerLevel( mContext,  getGeodiffLoggerLevel() );
104✔
1063
    }
104✔
1064
    ~GeodiffContext()
104✔
1065
    {
1066
      if ( mContext )
104✔
1067
      {
1068
        GEODIFF_CX_destroy( mContext );
104✔
1069
        mContext = nullptr;
104✔
1070
      }
1071
    }
104✔
1072
    GEODIFF_ContextH handle() {return mContext;}
95✔
1073

1074
  private:
1075
    GEODIFF_ContextH mContext = nullptr;
1076
};
1077

1078

1079
int main( int argc, char *argv[] )
104✔
1080
{
1081
  GeodiffContext context;
104✔
1082

1083
  std::vector<std::string> args;
104✔
1084
  for ( int i = 1; i < argc; ++i )
446✔
1085
    args.push_back( argv[i] );
684✔
1086

1087
  if ( args.size() < 1 )
104✔
1088
  {
1089
    std::cout << "Error: missing command. See 'geodiff help' for a list of commands." << std::endl;
1✔
1090
    return 1;
1✔
1091
  }
1092

1093
  std::string command = args[0];
103✔
1094
  if ( command == "diff" )
103✔
1095
  {
1096
    return handleCmdDiff( context.handle(), args );
18✔
1097
  }
1098
  else if ( command == "apply" )
85✔
1099
  {
1100
    return handleCmdApply( context.handle(), args );
13✔
1101
  }
1102
  else if ( command == "rebase-diff" )
72✔
1103
  {
1104
    return handleCmdRebaseDiff( context.handle(),  args );
10✔
1105
  }
1106
  else if ( command == "rebase-db" )
62✔
1107
  {
1108
    return handleCmdRebaseDb( context.handle(),  args );
9✔
1109
  }
1110
  else if ( command == "invert" )
53✔
1111
  {
1112
    return handleCmdInvert( context.handle(),  args );
6✔
1113
  }
1114
  else if ( command == "concat" )
47✔
1115
  {
1116
    return handleCmdConcat( context.handle(), args );
4✔
1117
  }
1118
  else if ( command == "as-json" )
43✔
1119
  {
1120
    return handleCmdAsJson( context.handle(),  args );
8✔
1121
  }
1122
  else if ( command == "as-summary" )
35✔
1123
  {
1124
    return handleCmdAsSummary( context.handle(),  args );
4✔
1125
  }
1126
  else if ( command == "copy" )
31✔
1127
  {
1128
    return handleCmdCopy( context.handle(), args );
12✔
1129
  }
1130
  else if ( command == "schema" )
19✔
1131
  {
1132
    return handleCmdSchema( context.handle(),  args );
5✔
1133
  }
1134
  else if ( command == "dump" )
14✔
1135
  {
1136
    return handleCmdDump( context.handle(), args );
6✔
1137
  }
1138
  else if ( command == "drivers" )
8✔
1139
  {
1140
    return handleCmdDrivers( args );
2✔
1141
  }
1142
  else if ( command == "version" )
6✔
1143
  {
1144
    return handleCmdVersion( args );
3✔
1145
  }
1146
  else if ( command == "help" )
3✔
1147
  {
1148
    return handleCmdHelp( args );
2✔
1149
  }
1150
  else
1151
  {
1152
    std::cout << "Error: unknown command '" << command << "'. See 'geodiff help' for a list of commands." << std::endl;
1✔
1153
    return 1;
1✔
1154
  }
1155
}
104✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc