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

devonfw / IDEasy / 43218131968

12 Nov 2026 12:27PM UTC coverage: 75.432% (+0.02%) from 75.414%
43218131968

push

github

web-flow
#2846: resolve variables and functions in project templates (#2854)

6472 of 9501 branches covered (68.12%)

Branch coverage included in aggregate %.

16519 of 20978 relevant lines covered (78.74%)

3.4 hits per line

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

80.06
cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java
1
package com.devonfw.tools.ide.commandlet.update;
2

3
import java.io.BufferedReader;
4
import java.io.BufferedWriter;
5
import java.io.IOException;
6
import java.nio.file.Files;
7
import java.nio.file.Path;
8
import java.util.HashSet;
9
import java.util.Iterator;
10
import java.util.List;
11
import java.util.Objects;
12
import java.util.Set;
13
import java.util.stream.Stream;
14

15
import org.slf4j.Logger;
16
import org.slf4j.LoggerFactory;
17

18
import com.devonfw.tools.ide.cli.CliException;
19
import com.devonfw.tools.ide.cli.CliFatalException;
20
import com.devonfw.tools.ide.commandlet.AbstractCommandlet;
21
import com.devonfw.tools.ide.commandlet.CommandletManager;
22
import com.devonfw.tools.ide.commandlet.CreateCommandlet;
23
import com.devonfw.tools.ide.commandlet.update.settings.SettingsHealthCheckResult;
24
import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdateResult;
25
import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdateStatus;
26
import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdater;
27
import com.devonfw.tools.ide.context.AbstractIdeContext;
28
import com.devonfw.tools.ide.context.IdeContext;
29
import com.devonfw.tools.ide.context.IdeStartContextImpl;
30
import com.devonfw.tools.ide.environment.EnvironmentVariables;
31
import com.devonfw.tools.ide.git.repository.RepositoryCommandlet;
32
import com.devonfw.tools.ide.git.repository.RepositoryType;
33
import com.devonfw.tools.ide.io.FileAccess;
34
import com.devonfw.tools.ide.property.FlagProperty;
35
import com.devonfw.tools.ide.property.StringProperty;
36
import com.devonfw.tools.ide.step.Step;
37
import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet;
38
import com.devonfw.tools.ide.tool.AbstractToolCommandlet;
39
import com.devonfw.tools.ide.tool.ToolEdition;
40
import com.devonfw.tools.ide.tool.ToolEditionAndVersion;
41
import com.devonfw.tools.ide.tool.ToolInstallRequest;
42
import com.devonfw.tools.ide.tool.ToolInstallTracker;
43
import com.devonfw.tools.ide.tool.custom.CustomToolCommandlet;
44
import com.devonfw.tools.ide.tool.custom.CustomToolMetadata;
45
import com.devonfw.tools.ide.tool.extra.ExtraToolInstallation;
46
import com.devonfw.tools.ide.tool.extra.ExtraTools;
47
import com.devonfw.tools.ide.tool.extra.ExtraToolsMapper;
48
import com.devonfw.tools.ide.util.FilenameUtil;
49
import com.devonfw.tools.ide.variable.IdeVariables;
50
import com.devonfw.tools.ide.version.VersionIdentifier;
51

52
/**
53
 * Abstract {@link AbstractCommandlet} base-class for both {@link UpdateCommandlet} and {@link CreateCommandlet}.
54
 */
55
public abstract class AbstractUpdateCommandlet extends AbstractCommandlet {
56

57
  private static final Logger LOG = LoggerFactory.getLogger(AbstractUpdateCommandlet.class);
3 ✔
58

59
  private static final Set<String> TEXTUAL_EXTENSIONS = Set.of("txt", "adoc", "xml", "json", "md", "asciidoc", "yaml", "yml", "properties");
12 ✔
60

61
  /** {@link StringProperty} for the settings repository URL. */
62
  public final StringProperty settingsRepo;
63

64
  /** {@link FlagProperty} for skipping installation/updating of tools. */
65
  public final FlagProperty skipTools;
66

67
  /** {@link FlagProperty} for skipping the setup of git repositories. */
68
  public final FlagProperty skipRepositories;
69

70
  /** {@link FlagProperty} to force the update of the settings git repository. */
71
  public final FlagProperty forcePull;
72

73
  /** {@link FlagProperty} to force the installation/update of plugins. */
74
  public final FlagProperty forcePlugins;
75

76
  /** {@link FlagProperty} to force the setup of git repositories. */
77
  public final FlagProperty forceRepositories;
78

79
  /**
80
   * The constructor.
81
   *
82
   * @param context the {@link IdeContext}.
83
   */
84
  public AbstractUpdateCommandlet(IdeContext context) {
85

86
    super(context);
3 ✔
87
    addKeyword(getName());
4 ✔
88
    this.skipTools = add(new FlagProperty("--skip-tools"));
9 ✔
89
    this.skipRepositories = add(new FlagProperty("--skip-repositories"));
9 ✔
90
    this.forcePull = add(new FlagProperty("--force-pull"));
9 ✔
91
    this.forcePlugins = add(new FlagProperty("--force-plugins"));
9 ✔
92
    this.forceRepositories = add(new FlagProperty("--force-repositories"));
9 ✔
93
    this.settingsRepo = new StringProperty("", false, "settingsRepository");
8 ✔
94
  }
1 ✔
95

96
  @Override
97
  protected void doRun() {
98

99
    IdeStartContextImpl startContext = ((AbstractIdeContext) this.context).getStartContext();
5 ✔
100
    startContext.setForcePull(forcePull.isTrue());
5 ✔
101
    startContext.setForcePlugins(forcePlugins.isTrue());
5 ✔
102
    startContext.setForceRepositories(forceRepositories.isTrue());
5 ✔
103

104
    updateSettings();
2 ✔
105
    updateProjectTemplates();
2 ✔
106
    reloadContext();
2 ✔
107
    this.context.verifyIdeMinVersion(true);
4 ✔
108

109
    updateSoftware();
2 ✔
110
    updateRepositories();
2 ✔
111
    createStartScripts();
2 ✔
112
  }
1 ✔
113

114
  /**
115
   * Hook that is called after the settings passed the health check but before they are moved to their final location. Does nothing by default and is overridden
116
   * by {@link CreateCommandlet} to create the project structure so that no project is created at all if the health check failed.
117
   */
118
  protected void onSettingHealthCheckFinished() {
119

120
    // nothing to do by default
121
  }
1 ✔
122

123
  private void reloadContext() {
124

125
    ((AbstractIdeContext) this.context).reload();
4 ✔
126
  }
1 ✔
127

128
  private void updateProjectTemplates() {
129

130
    Path templatesFolder = this.context.getSettingsPath().resolve(IdeContext.FOLDER_TEMPLATES);
6 ✔
131
    if (!Files.exists(templatesFolder)) {
5 ✔
132
      Path legacyTemplatesFolder = this.context.getSettingsPath().resolve(IdeContext.FOLDER_LEGACY_TEMPLATES);
6 ✔
133
      if (Files.exists(legacyTemplatesFolder)) {
5 !
134
        templatesFolder = legacyTemplatesFolder;
×
135
      } else {
136
        LOG.warn("Templates folder is missing in settings repository.");
3 ✔
137
        return;
1 ✔
138
      }
139
    }
140

141
    Step step = this.context.newStep("Copy configuration templates", templatesFolder);
11 ✔
142
    final Path finalTemplatesFolder = templatesFolder;
2 ✔
143
    step.run(() -> setupProjectTemplates(finalTemplatesFolder, this.context.getIdeHome()));
13 ✔
144
  }
1 ✔
145

146
  private void setupProjectTemplates(Path templatePath, Path projectPath) {
147

148
    EnvironmentVariables variables = this.context.getVariables();
4 ✔
149
    List<Path> templateChildren = this.context.getFileAccess().listChildren(templatePath, f -> true);
9 ✔
150
    for (Path templateChild : templateChildren) {
10 ✔
151

152
      String basename = templateChild.getFileName().toString();
4 ✔
153
      Path projectChild = projectPath.resolve(basename);
4 ✔
154

155
      if (Files.isDirectory(templateChild)) {
5 ✔
156
        if (!Files.isDirectory(projectChild)) {
5 !
157
          this.context.getFileAccess().mkdirs(projectChild);
5 ✔
158
        }
159
        setupProjectTemplates(templateChild, projectChild);
5 ✔
160
      } else if (Files.isRegularFile(templateChild)) {
5 !
161
        if (Files.isRegularFile(projectChild)) {
5 !
162
          LOG.debug("Configuration {} already exists - skipping to instantiate from {}", projectChild, templateChild);
×
163
        } else {
164
          if (basename.equals("settings.xml")) {
4 ✔
165
            LOG.debug("Skipping settings.xml that will later be instantiated via mvn.");
4 ✔
166
          } else {
167
            this.context.newStep("Instantiate template", templateChild, projectChild).run(() ->
21 ✔
168
                setupProjectTemplate(templateChild, projectChild, variables));
6 ✔
169
          }
170
        }
171
      }
172
    }
1 ✔
173
  }
1 ✔
174

175
  private void setupProjectTemplate(Path templateFile, Path projectFile, EnvironmentVariables variables) {
176
    String basename = templateFile.getFileName().toString();
4 ✔
177
    String extension = FilenameUtil.getExtension(basename);
3 ✔
178
    if (TEXTUAL_EXTENSIONS.contains(extension)) {
4 ✔
179
      instantiateProjectTemplate(templateFile, projectFile, variables);
6 ✔
180
    } else {
181
      this.context.getFileAccess().copy(templateFile, projectFile.getParent());
7 ✔
182
    }
183
  }
1 ✔
184

185
  private void instantiateProjectTemplate(Path templateFile, Path projectFile, EnvironmentVariables variables) {
186

187
    try (BufferedReader reader = Files.newBufferedReader(templateFile);
3 ✔
188
        BufferedWriter writer = Files.newBufferedWriter(projectFile)
5 ✔
189
    ) {
190
      String line;
191
      do {
192
        line = reader.readLine();
3 ✔
193
        if (line != null) {
2 ✔
194
          String resolvedLine = variables.resolve(line, templateFile, false);
6 ✔
195
          writer.write(resolvedLine);
3 ✔
196
          writer.write('\n');
3 ✔
197
        }
198
      } while (line != null);
2 ✔
199
    } catch (IOException e) {
×
200
      throw new IllegalStateException("Failed to instantiate template ", e);
×
201
    }
1 ✔
202
  }
1 ✔
203

204

205
  /**
206
   * Updates the settings repository in IDE_HOME/settings by either cloning if no such repository exists or pulling if the repository exists then saves the
207
   * latest current commit ID in the file ".commit.id". The settings are always cloned into a temporary directory first where a health check is performed. Only
208
   * if that health check succeeded the settings are pulled or the verified clone is moved to its final location.
209
   */
210
  protected void updateSettings() {
211

212
    RepositoryType repositoryType = RepositoryType.ofSettingsPath(getSettingsPathForSettingsUpdate(), context);
6 ✔
213
    if (repositoryType == RepositoryType.CODE_SETTINGS_COMBINED && !(this.context.isForceMode() || this.forcePull.isTrue())) {
3 !
214
      LOG.info("Skipping git pull in settings due to code repository. Use --force-pull to enforce pulling.");
×
215
      return;
×
216
    }
217
    Step step = this.context.newStep(getStepMessage());
6 ✔
218
    step.run(this::updateSettingsInStep);
5 ✔
219
  }
1 ✔
220

221
  protected String getStepMessage() {
222

223
    return "Update settings repository";
2 ✔
224
  }
225

226
  private void updateSettingsInStep() {
227

228
    SettingsUpdater settingsUpdater = new SettingsUpdater(this.context, this.settingsRepo);
8 ✔
229
    try {
230
      //Step 1: Perform health check
231
      Step healthCheckStep = this.context.newStep("Performing settings health check");
5 ✔
232
      SettingsHealthCheckResult healthCheckResult = healthCheckStep.call(() -> checkSettingsInStep(settingsUpdater), () -> null);
12 ✔
233

234
      // If the health check failed (healthCheckResult is null) the settings have not been verified, so skip applying them and fail the "Update settings"
235
      // step. A non-null result is only produced when the health check passed or the user explicitly chose to continue anyway (force mode), so this never
236
      // aborts in force mode.
237
      if (healthCheckResult == null) {
2 !
238
        throw new CliException("Settings update aborted due to error in health check");
×
239
      }
240

241
      //Step 2: Let create/update commandlets prepare themselves for the settings update.
242
      onSettingHealthCheckFinished();
2 ✔
243

244
      //Step 3: Apply (move/pull newest version) settings
245
      Step applySettingsStep = this.context.newStep("Applying settings");
5 ✔
246
      applySettingsStep.run(() -> applySettingsUpdateInStep(settingsUpdater, healthCheckResult));
12 ✔
247

248
      //Make sure to always fail the parent step if the "Apply settings" step fails.
249
      if (applySettingsStep.isFailure()) {
3 ✔
250
        throw new CliException("Settings update failed due to error while applying the settings update");
5 ✔
251
      }
252
    } finally {
253
      // the verified clone lives across both steps and the prepareProject hook so it is only here that its lifetime ends
254
      settingsUpdater.cleanup();
2 ✔
255
    }
256
  }
1 ✔
257

258
  private SettingsHealthCheckResult checkSettingsInStep(SettingsUpdater settingsUpdater) {
259
    SettingsHealthCheckResult healthCheckResult = settingsUpdater.checkSettings(getSettingsPathForSettingsUpdate());
5 ✔
260
    RepositoryType repositoryType = healthCheckResult.repositoryType();
3 ✔
261

262
    if ((repositoryType == null || !repositoryType.isValid()) && !healthCheckResult.isExistingProject()) {
8 !
263
      throw new CliFatalException("Fatal error while cloning settings: The settings health check failed: " + healthCheckResult.errorMessage());
7 ✔
264
    } else if ((repositoryType == null || !repositoryType.isValid())) {
5 !
265
      throw new CliException("The settings health check failed: " + healthCheckResult.errorMessage());
×
266
    }
267
    return healthCheckResult;
2 ✔
268
  }
269

270
  private void applySettingsUpdateInStep(SettingsUpdater settingsUpdater, SettingsHealthCheckResult healthCheckResult) {
271
    SettingsUpdateResult settingsUpdateResult = settingsUpdater.applySettings(healthCheckResult);
4 ✔
272
    if (settingsUpdateResult == null) {
2 !
273

274
      throw new CliException("Failed to apply the settings update due to unknown error.");
×
275
    }
276

277
    if (Objects.requireNonNull(settingsUpdateResult.updateStatus()) == SettingsUpdateStatus.SETTINGS_UPDATE_FAILED) {
5 !
278
      String errorMessage = "The settings update could not be applied: " + settingsUpdateResult.errorMessage();
×
279
      if (!healthCheckResult.isExistingProject()) {
×
280
        throw new CliFatalException(errorMessage);
×
281
      }
282
      throw new CliException(errorMessage);
×
283
    }
284
  }
1 ✔
285

286
  private void updateSoftware() {
287

288
    if (this.skipTools.isTrue()) {
4 ✔
289
      LOG.info("Skipping installation/update of tools as specified by the user.");
3 ✔
290
      return;
1 ✔
291
    }
292
    Step step = this.context.newStep("Install or update software");
5 ✔
293
    step.run(() -> doUpdateSoftwareStep(step));
10 ✔
294
  }
1 ✔
295

296
  private void doUpdateSoftwareStep(Step step) {
297

298
    Set<AbstractToolCommandlet> toolCommandlets = new HashSet<>();
4 ✔
299
    CommandletManager commandletManager = this.context.getCommandletManager();
4 ✔
300
    // installed tools in IDE_HOME/software
301
    List<Path> softwarePaths = this.context.getFileAccess().listChildren(this.context.getSoftwarePath(), Files::isDirectory);
14 ✔
302
    for (Path softwarePath : softwarePaths) {
10 ✔
303
      String toolName = softwarePath.getFileName().toString();
4 ✔
304
      AbstractToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(toolName);
4 ✔
305
      if (toolCommandlet != null) {
2 !
306
        toolCommandlets.add(toolCommandlet);
4 ✔
307
      }
308
    }
1 ✔
309

310
    // regular tools in $IDE_TOOLS
311
    List<String> regularTools = IdeVariables.IDE_TOOLS.get(this.context);
6 ✔
312
    if (regularTools != null) {
2 !
313
      for (String regularTool : regularTools) {
10 ✔
314
        AbstractToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(regularTool);
4 ✔
315
        if (toolCommandlet == null) {
2 !
316
          String displayName = (regularTool == null || regularTool.isBlank()) ? "<empty>" : "'" + regularTool + "'";
×
317
          LOG.error("Cannot install or update tool '{}''. No matching commandlet found. Please check your IDE_TOOLS configuration.", displayName);
×
318
        } else {
×
319
          toolCommandlets.add(toolCommandlet);
4 ✔
320
        }
321
      }
1 ✔
322
    }
323

324
    // custom tools in ide-custom-tools.json
325
    for (CustomToolMetadata customTool : this.context.getCustomToolRepository().getTools()) {
9 !
326
      CustomToolCommandlet customToolCommandlet = new CustomToolCommandlet(this.context, customTool);
×
327
      toolCommandlets.add(customToolCommandlet);
×
328
    }
×
329

330
    // Track tools installed during this operation so a shared dependency is not installed and CVE-checked redundantly. A single tracker is shared across
331
    // all tools so cross-tool references are deduplicated too - see https://github.com/devonfw/IDEasy/issues/945
332
    ToolInstallTracker tracker = new ToolInstallTracker();
4 ✔
333

334
    // update/install the toolCommandlets
335
    for (AbstractToolCommandlet toolCommandlet : toolCommandlets) {
10 ✔
336
      this.context.newStep("Install " + toolCommandlet.getName()).run(() -> toolCommandlet.install(tracker));
16 ✔
337
    }
1 ✔
338

339
    ExtraTools extraTools = ExtraToolsMapper.get().loadJsonFromFolder(this.context.getSettingsPath());
7 ✔
340
    if (extraTools != null) {
2 ✔
341
      List<String> toolNames = extraTools.getSortedToolNames();
3 ✔
342
      LOG.info("Found extra installation of the following tools: {}", toolNames);
4 ✔
343
      for (String tool : toolNames) {
10 ✔
344
        List<ExtraToolInstallation> installations = extraTools.getExtraInstallations(tool);
4 ✔
345
        this.context.newStep("Install extra version(s) of " + tool).run(() -> installExtraToolInstallations(tool, installations, tracker));
18 ✔
346
      }
1 ✔
347
    }
348

349
  }
1 ✔
350

351
  private void installExtraToolInstallations(String tool, List<ExtraToolInstallation> extraInstallations, ToolInstallTracker tracker) {
352

353
    CommandletManager commandletManager = this.context.getCommandletManager();
4 ✔
354
    FileAccess fileAccess = this.context.getFileAccess();
4 ✔
355
    Path extraPath = this.context.getSoftwareExtraPath();
4 ✔
356
    AbstractLocalToolCommandlet toolCommandlet = commandletManager.getRequiredLocalToolCommandlet(tool);
4 ✔
357
    for (ExtraToolInstallation extraInstallation : extraInstallations) {
10 ✔
358
      ToolInstallRequest request = new ToolInstallRequest(false);
5 ✔
359
      String edition = extraInstallation.edition();
3 ✔
360
      if (edition == null) {
2 ✔
361
        edition = toolCommandlet.getConfiguredEdition();
3 ✔
362
      }
363
      ToolEdition toolEdition = new ToolEdition(tool, edition);
6 ✔
364
      VersionIdentifier version = extraInstallation.version();
3 ✔
365
      request.setRequested(new ToolEditionAndVersion(toolEdition, version));
7 ✔
366
      Path extraToolPath = extraPath.resolve(tool);
4 ✔
367
      Path toolPath = extraToolPath.resolve(extraInstallation.name());
5 ✔
368
      request.setToolPathForExtraInstallation(toolPath);
3 ✔
369
      request.setTracker(tracker);
3 ✔
370
      toolCommandlet.install(request);
4 ✔
371
    }
1 ✔
372
  }
1 ✔
373

374
  private void updateRepositories() {
375

376
    if (this.skipRepositories.isTrue()) {
4 !
377
      if (this.forceRepositories.isTrue()) {
×
378
        LOG.warn("Options to skip and force repositories are incompatible and should not be combined. Ignoring --force-repositories to proceed.");
×
379
      }
380
      LOG.info("Skipping setup of repositories as specified by the user.");
×
381
      return;
×
382
    }
383
    RepositoryCommandlet repositoryCommandlet = this.context.getCommandletManager().getCommandlet(RepositoryCommandlet.class);
7 ✔
384
    repositoryCommandlet.reset();
2 ✔
385
    repositoryCommandlet.run();
2 ✔
386
  }
1 ✔
387

388
  private void createStartScripts() {
389

390
    List<String> ides = IdeVariables.CREATE_START_SCRIPTS.get(this.context);
6 ✔
391
    if (ides == null) {
2 ✔
392
      LOG.info("Variable CREATE_START_SCRIPTS is undefined - skipping start script creation.");
3 ✔
393
      return;
1 ✔
394
    }
395
    for (String ide : ides) {
10 ✔
396
      AbstractToolCommandlet tool = this.context.getCommandletManager().getToolCommandlet(ide);
6 ✔
397
      if (tool == null) {
2 !
398
        LOG.error("Undefined IDE '{}' configured in variable CREATE_START_SCRIPTS.", ide);
×
399
      } else {
400
        createStartScript(ide);
3 ✔
401
      }
402
    }
1 ✔
403
  }
1 ✔
404

405
  private void createStartScript(String ide) {
406

407
    LOG.info("Creating start scripts for {}", ide);
4 ✔
408
    Path workspaces = this.context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES);
6 ✔
409
    try (Stream<Path> childStream = Files.list(workspaces)) {
3 ✔
410
      Iterator<Path> iterator = childStream.iterator();
3 ✔
411
      while (iterator.hasNext()) {
3 ✔
412
        Path child = iterator.next();
4 ✔
413
        if (Files.isDirectory(child)) {
5 !
414
          createStartScript(ide, child.getFileName().toString());
6 ✔
415
        }
416
      }
1 ✔
417
    } catch (IOException e) {
×
418
      throw new RuntimeException("Failed to list children of directory " + workspaces, e);
×
419
    }
1 ✔
420
  }
1 ✔
421

422
  private void createStartScript(String ide, String workspace) {
423

424
    Path ideHome = this.context.getIdeHome();
4 ✔
425
    String scriptName = ide + "-" + workspace;
4 ✔
426
    boolean windows = this.context.getSystemInfo().isWindows();
5 ✔
427
    if (windows) {
2 !
428
      scriptName = scriptName + ".bat";
×
429
    } else {
430
      scriptName = scriptName + ".sh";
3 ✔
431
    }
432
    Path scriptPath = ideHome.resolve(scriptName);
4 ✔
433
    if (Files.exists(scriptPath)) {
5 !
434
      return;
×
435
    }
436
    String scriptContent;
437
    if (windows) {
2 !
438
      scriptContent = "@echo off\r\n" + "pushd %~dp0\r\n" + "cd workspaces/" + workspace + "\r\n" + "call ide " + ide + "\r\n" + "popd\r\n";
×
439
    } else {
440
      scriptContent = "#!/usr/bin/env bash\n" + "cd \"$(dirname \"$0\")\"\n" + "cd workspaces/" + workspace + "\n" + "ideasy " + ide + "\n";
4 ✔
441
    }
442
    FileAccess fileAccess = this.context.getFileAccess();
4 ✔
443
    fileAccess.writeFileContent(scriptContent, scriptPath);
4 ✔
444
    fileAccess.makeExecutable(scriptPath);
3 ✔
445
  }
1 ✔
446

447
  /**
448
   * This method returns the path to the settings for the case of a settings update.
449
   *
450
   * @return The {@link Path} to the settings folder; if not overridden we will get the path from the context
451
   */
452
  protected Path getSettingsPathForSettingsUpdate() {
453

454
    return this.context.getSettingsPath();
4 ✔
455
  }
456
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc