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

devonfw / IDEasy / 28156088945

25 Jun 2026 08:07AM UTC coverage: 71.354% (-0.002%) from 71.356%
28156088945

Pull #2023

github

web-flow
Merge 32dd6feb9 into 459b15501
Pull Request #2023: #1594: Implement release commandlet

4719 of 7322 branches covered (64.45%)

Branch coverage included in aggregate %.

12182 of 16364 relevant lines covered (74.44%)

3.15 hits per line

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

36.19
cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java
1
package com.devonfw.tools.ide.git;
2

3
import java.io.IOException;
4
import java.nio.file.Files;
5
import java.nio.file.Path;
6
import java.util.ArrayList;
7
import java.util.List;
8
import java.util.Objects;
9

10
import org.slf4j.Logger;
11
import org.slf4j.LoggerFactory;
12

13
import com.devonfw.tools.ide.cli.CliException;
14
import com.devonfw.tools.ide.context.IdeContext;
15
import com.devonfw.tools.ide.log.IdeLogLevel;
16
import com.devonfw.tools.ide.os.SystemInfoImpl;
17
import com.devonfw.tools.ide.process.ProcessContext;
18
import com.devonfw.tools.ide.process.ProcessErrorHandling;
19
import com.devonfw.tools.ide.process.ProcessMode;
20
import com.devonfw.tools.ide.process.ProcessResult;
21
import com.devonfw.tools.ide.variable.IdeVariables;
22

23
/**
24
 * Implements the {@link GitContext}.
25
 */
26
public class GitContextImpl implements GitContext {
27

28
  private static final Logger LOG = LoggerFactory.getLogger(GitContextImpl.class);
4✔
29

30
  /** @see #getContext() */
31
  protected final IdeContext context;
32
  private Path git;
33

34
  /**
35
   * @param context the {@link IdeContext context}.
36
   */
37
  public GitContextImpl(IdeContext context) {
2✔
38

39
    this.context = context;
3✔
40
  }
1✔
41

42
  @Override
43
  public void pullOrCloneIfNeeded(GitUrl gitUrl, Path repository) {
44

45
    GitOperation.PULL_OR_CLONE.executeIfNeeded(this.context, gitUrl, repository, null);
8✔
46
  }
1✔
47

48
  @Override
49
  public boolean fetchIfNeeded(Path repository) {
50

51
    return fetchIfNeeded(repository, null, null);
×
52
  }
53

54
  @Override
55
  public boolean fetchIfNeeded(Path repository, String remote, String branch) {
56

57
    return GitOperation.FETCH.executeIfNeeded(this.context, new GitUrl("https://dummy.url/repo.git", branch), repository, remote);
12✔
58
  }
59

60
  @Override
61
  public boolean isRepositoryUpdateAvailable(Path repository) {
62

63
    if (!hasUpstream(repository)) {
4!
64
      return false;
2✔
65
    }
66
    String localFailureMessage = String.format("Failed to get the local commit id of settings repository '%s'.", repository);
×
67
    String remoteFailureMessage = String.format("Failed to get the remote commit id of settings repository '%s', missing remote upstream branch?", repository);
×
68
    String localCommitId = runGitCommandAndGetSingleOutput(localFailureMessage, repository, ProcessMode.DEFAULT_CAPTURE, "rev-parse", "HEAD");
×
69
    String remoteCommitId = runGitCommandAndGetSingleOutput(remoteFailureMessage, repository, ProcessMode.DEFAULT_CAPTURE, "rev-parse", "@{u}");
×
70
    if ((localCommitId == null) || (remoteCommitId == null)) {
×
71
      return false;
×
72
    }
73
    return !localCommitId.equals(remoteCommitId);
×
74
  }
75

76
  @Override
77
  public boolean isRepositoryUpdateAvailable(Path repository, Path trackedCommitIdPath) {
78

79
    String trackedCommitId = this.context.getFileAccess().readFileContent(trackedCommitIdPath);
×
80
    if (trackedCommitId == null) {
×
81
      LOG.warn("Commit ID was not present at {}", trackedCommitIdPath);
×
82
      return true;
×
83
    }
84
    if (!hasUpstream(repository)) {
×
85
      return false;
×
86
    }
87
    String remoteFailureMessage = String.format("Failed to get the remote commit id of settings repository '%s', missing remote upstream branch?", repository);
×
88
    String remoteCommitId = runGitCommandAndGetSingleOutput(remoteFailureMessage, repository, ProcessMode.DEFAULT_CAPTURE, "rev-parse", "@{u}");
×
89
    if (remoteCommitId == null) {
×
90
      return false;
×
91
    }
92
    return !trackedCommitId.equals(remoteCommitId);
×
93
  }
94

95
  @Override
96
  public void pullOrCloneAndResetIfNeeded(GitUrl gitUrl, Path repository, String remoteName) {
97

98
    pullOrCloneIfNeeded(gitUrl, repository);
4✔
99
    reset(repository, gitUrl.branch(), remoteName);
6✔
100
    cleanup(repository);
3✔
101
  }
1✔
102

103
  @Override
104
  public void pullSafelyWithStash(Path repository) {
105
    String token = "autostash:pull:" + java.util.UUID.randomUUID();
×
106
    LOG.debug("Untracked files found. Creating temporary stash with token '{}'", token);
×
107
    ProcessResult stashRes = runGitCommand(repository, ProcessMode.DEFAULT, "--no-pager", "stash", "push", "--include-untracked", "-m", token, "--quiet");
×
108
    if (!stashRes.isSuccessful()) {
×
109
      LOG.warn("Failed to create stash before pull on {}", repository);
×
110
      handleErrors(repository, stashRes);
×
111
    }
112

113
    ProcessResult listRes = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "--no-pager", "stash", "list");
×
114
    if (!listRes.isSuccessful()) {
×
115
      LOG.warn("Failed to list stash after creating temporary stash on {}", repository);
×
116
      handleErrors(repository, listRes);
×
117
    }
118

119
    String stashRef = findStashRefByMessage(listRes.getOut(), token);
×
120
    if (stashRef == null) {
×
121
      LOG.warn("Could not find created stash by token '{}'. Leaving stash untouched.", token);
×
122
    } else {
123
      LOG.debug("Created stash identified as '{}'", stashRef);
×
124
    }
125

126
    pull(repository);
×
127

128
    if (stashRef != null) {
×
129
      ProcessResult popRes = runGitCommand(repository, ProcessMode.DEFAULT, "--no-pager", "stash", "pop", stashRef, "--quiet");
×
130
      if (!popRes.isSuccessful()) {
×
131
        LOG.warn("Applying stash {} failed after successful pull on {}.", stashRef, repository);
×
132
        handleErrors(repository, popRes);
×
133
      } else {
134
        LOG.debug("Stash {} successfully popped after pull.", stashRef);
×
135
      }
136
    } else {
×
137
      LOG.warn("Skipping stash pop because stashRef is unknown (token '{}'). Stash remains on the stack.", token);
×
138
    }
139
  }
×
140

141
  @Override
142
  public boolean hasUntrackedFiles(Path repository) {
143
    ProcessResult status = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "--no-pager", "status", "--porcelain", "-uall");
×
144
    if (!status.isSuccessful()) {
×
145
      handleErrors(repository, status);
×
146
      return false;
×
147
    }
148
    List<String> out = status.getOut();
×
149
    return !out.isEmpty();
×
150
  }
151

152
  private String findStashRefByMessage(List<String> stashList, String needle) {
153
    if (stashList.isEmpty()) {
×
154
      return null;
×
155
    }
156
    for (String line : stashList) {
×
157
      if (line.contains(needle)) {
×
158
        int idx = line.indexOf(':');
×
159
        if (idx > 0) {
×
160
          return line.substring(0, idx).trim();
×
161
        }
162
      }
163
    }
×
164
    return null;
×
165
  }
166

167

168
  @Override
169
  public void pullOrClone(GitUrl gitUrl, Path repository) {
170

171
    Objects.requireNonNull(repository);
3✔
172
    Objects.requireNonNull(gitUrl);
3✔
173
    if (Files.isDirectory(repository.resolve(GIT_FOLDER))) {
7✔
174
      // checks for remotes
175
      String remote = determineRemote(repository);
4✔
176
      if (remote == null) {
2!
177
        String message = repository + " is a local git repository with no remote - if you did this for testing, you may continue...\n"
×
178
            + "Do you want to ignore the problem and continue anyhow?";
179
        this.context.askToContinue(message);
×
180
      } else {
×
181
        pull(repository);
3✔
182
      }
183
    } else {
1✔
184
      clone(gitUrl, repository);
4✔
185
    }
186
  }
1✔
187

188
  /**
189
   * Handles errors which occurred during git pull.
190
   *
191
   * @param targetRepository the {@link Path} to the target folder where the git repository should be cloned or pulled. It is not the parent directory where
192
   *     git will by default create a sub-folder by default on clone but the * final folder that will contain the ".git" subfolder.
193
   * @param result the {@link ProcessResult} to evaluate.
194
   */
195
  private void handleErrors(Path targetRepository, ProcessResult result) {
196

197
    if (!result.isSuccessful()) {
×
198
      String message = "Failed to update git repository at " + targetRepository;
×
199
      if (this.context.isOfflineMode()) {
×
200
        LOG.warn(message);
×
201
        IdeLogLevel.INTERACTION.log(LOG, "Continuing as we are in offline mode - results may be outdated!");
×
202
      } else {
203
        LOG.error(message);
×
204
        if (this.context.isOnline()) {
×
205
          LOG.error("See above error for details. If you have local changes, please stash or revert and retry.");
×
206
        } else {
207
          LOG.error("It seems you are offline - please ensure Internet connectivity and retry or activate offline mode (-o or --offline).");
×
208
        }
209
        this.context.askToContinue("Typically you should abort and fix the problem. Do you want to continue anyways?");
×
210
      }
211
    }
212
  }
×
213

214
  @Override
215
  public void clone(GitUrl gitUrl, Path repository) {
216

217
    GitUrlSyntax gitUrlSyntax = IdeVariables.PREFERRED_GIT_PROTOCOL.get(getContext());
6✔
218
    gitUrl = gitUrlSyntax.format(gitUrl);
4✔
219
    if (this.context.isOfflineMode()) {
4✔
220
      this.context.requireOnline("git clone of " + gitUrl, false);
×
221
    }
222
    this.context.getFileAccess().mkdirs(repository);
5✔
223
    List<String> args = new ArrayList<>(7);
5✔
224
    args.add("clone");
4✔
225
    if (this.context.isQuietMode()) {
4!
226
      args.add("-q");
×
227
    }
228
    args.add("--recursive");
4✔
229
    args.add(gitUrl.url());
5✔
230
    args.add("--config");
4✔
231
    args.add("core.autocrlf=false");
4✔
232
    args.add(".");
4✔
233
    runGitCommand(repository, args);
4✔
234
    String branch = gitUrl.branch();
3✔
235
    if (branch != null) {
2✔
236
      runGitCommand(repository, "switch", branch);
13✔
237
    }
238
  }
1✔
239

240
  @Override
241
  public void pull(Path repository) {
242

243
    if (this.context.isOffline()) {
4!
244
      LOG.info("Skipping git pull on {} because offline", repository);
×
245
      return;
×
246
    }
247
    ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT, "--no-pager", "pull", "--quiet");
19✔
248
    if (!result.isSuccessful()) {
3!
249
      String branchName = determineCurrentBranch(repository);
×
250
      LOG.warn("Git pull on branch {} failed for repository {}.", branchName, repository);
×
251
      handleErrors(repository, result);
×
252
    }
253
  }
1✔
254

255
  @Override
256
  public void fetch(Path repository, String remote, String branch) {
257

258
    if (branch == null) {
2!
259
      branch = determineCurrentBranch(repository);
×
260
    }
261
    if (remote == null) {
2!
262
      remote = determineRemote(repository);
×
263
    }
264

265
    ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "fetch", Objects.requireNonNullElse(remote, "origin"), branch);
22✔
266

267
    if (!result.isSuccessful()) {
3!
268
      LOG.warn("Git fetch for '{}/{} failed.'.", remote, branch);
×
269
    }
270
  }
1✔
271

272
  @Override
273
  public String determineCurrentBranch(Path repository) {
274

275
    return runGitCommandAndGetSingleOutput("Failed to determine current branch of git repository", repository, "branch", "--show-current");
15✔
276
  }
277

278
  @Override
279
  public String determineRemote(Path repository) {
280

281
    return runGitCommandAndGetSingleOutput("Failed to determine current origin of git repository.", repository, "remote");
11✔
282
  }
283

284
  @Override
285
  public void reset(Path repository, String branchName, String remoteName) {
286

287
    if ((remoteName == null) || remoteName.isEmpty()) {
5!
288
      remoteName = DEFAULT_REMOTE;
2✔
289
    }
290
    if ((branchName == null) || branchName.isEmpty()) {
5!
291
      branchName = GitUrl.BRANCH_MASTER;
2✔
292
    }
293
    ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT, "diff-index", "--quiet", "HEAD");
19✔
294
    if (!result.isSuccessful()) {
3!
295
      // reset to origin/master
296
      LOG.warn("Git has detected modified files -- attempting to reset {} to '{}/{}'.", repository, remoteName, branchName);
17✔
297
      result = runGitCommand(repository, ProcessMode.DEFAULT, "reset", "--hard", remoteName + "/" + branchName);
21✔
298
      if (!result.isSuccessful()) {
3!
299
        LOG.warn("Git failed to reset {} to '{}/{}'.", remoteName, branchName, repository);
×
300
        handleErrors(repository, result);
×
301
      }
302
    }
303
  }
1✔
304

305
  @Override
306
  public void cleanup(Path repository) {
307

308
    // check for untracked files
309
    ProcessResult result = runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "ls-files", "--other", "--directory", "--exclude-standard");
23✔
310
    if (!result.getOut().isEmpty()) {
4✔
311
      // delete untracked files
312
      LOG.warn("Git detected untracked files in {} and is attempting a cleanup.", repository);
4✔
313
      runGitCommand(repository, "clean", "-df");
13✔
314
    }
315
  }
1✔
316

317
  @Override
318
  public String retrieveGitUrl(Path repository) {
319

320
    return runGitCommandAndGetSingleOutput("Failed to retrieve git URL for repository", repository, "config", "--get", "remote.origin.url");
×
321
  }
322

323
  @Override
324
  public List<String> retrieveGitRemotes(Path repository) {
325

326
    return runGitCommand(repository, ProcessMode.DEFAULT_CAPTURE, "--no-pager", "remote", "-v").getOut();
×
327
  }
328

329
  IdeContext getContext() {
330

331
    return this.context;
3✔
332
  }
333

334
  @Override
335
  public Path findGitRequired() {
336

337
    Path gitPath = findGit();
×
338
    if (gitPath == null) {
×
339
      String message = "Git " + IdeContext.IS_NOT_INSTALLED_BUT_REQUIRED;
×
340
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
341
        message += IdeContext.PLEASE_DOWNLOAD_AND_INSTALL_GIT + ":\n " + IdeContext.WINDOWS_GIT_DOWNLOAD_URL;
×
342
      }
343
      throw new CliException(message);
×
344
    }
345
    return gitPath;
×
346
  }
347

348
  @Override
349
  public Path findGit() {
350
    if (this.git != null) {
×
351
      return this.git;
×
352
    }
353

354
    Path gitPath = findGitInPath(Path.of("git"));
×
355

356
    if (gitPath == null) {
×
357
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
358
        gitPath = findGitOnWindowsViaBash();
×
359
      }
360
    }
361

362
    if (gitPath != null) {
×
363
      this.git = gitPath;
×
364
      LOG.trace("Found git at: {}", gitPath);
×
365
    }
366

367
    return gitPath;
×
368
  }
369

370
  private Path findGitOnWindowsViaBash() {
371
    Path gitPath;
372
    Path bashBinary = this.context.findBashRequired();
×
373
    LOG.trace("Trying to find git path on Windows");
×
374
    if (Files.exists(bashBinary)) {
×
375
      gitPath = bashBinary.getParent().resolve("git.exe");
×
376
      if (Files.exists(gitPath)) {
×
377
        LOG.trace("Git path was extracted from bash path at: {}", gitPath);
×
378
      } else {
379
        LOG.error("Git path: {} was extracted from bash path at: {} but it does not exist", gitPath, bashBinary);
×
380
        return null;
×
381
      }
382
    } else {
383
      LOG.error("Bash path was checked at: {} but it does not exist", bashBinary);
×
384
      return null;
×
385
    }
386
    return gitPath;
×
387
  }
388

389
  private Path findGitInPath(Path gitPath) {
390
    LOG.trace("Trying to find git executable within the PATH environment variable");
×
391
    Path binaryGitPath = this.context.getPath().findBinary(gitPath);
×
392
    if (gitPath == binaryGitPath) {
×
393
      LOG.debug("No git executable could be found within the PATH environment variable");
×
394
      return null;
×
395
    }
396
    return binaryGitPath;
×
397
  }
398

399
  /**
400
   * Retrieves an optional git config value from the given repository.
401
   *
402
   * @param directory the git repository directory.
403
   * @param key the git config key.
404
   * @return the config value or {@code null} if not available.
405
   */
406
  private String getOptionalGitConfigValue(Path directory, String key) {
407

408
    ProcessResult result = runGitCommand(
×
409
        directory,
410
        ProcessMode.DEFAULT_CAPTURE,
411
        ProcessErrorHandling.NONE,
412
        "config",
413
        "--get",
414
        key
415
    );
416

417
    if (result.isSuccessful() && result.getOut().size() == 1) {
×
418
      return result.getOut().getFirst();
×
419
    }
420

421
    return null;
×
422

423
  }
424

425
  /**
426
   * Checks if the current branch has an upstream remote branch configured.
427
   *
428
   * @param repository the git repository directory.
429
   * @return {@code true} if an upstream is configured, {@code false} otherwise.
430
   */
431
  private boolean hasUpstream(Path repository) {
432

433
    String branch = determineCurrentBranch(repository);
4✔
434
    if ((branch == null) || branch.isBlank()) {
2!
435
      return false;
2✔
436
    }
437

438
    String remote = getOptionalGitConfigValue(repository, "branch." + branch + ".remote");
×
439
    String merge = getOptionalGitConfigValue(repository, "branch." + branch + ".merge");
×
440

441
    if ((remote == null) || (merge == null)) {
×
442
      LOG.warn("No upstream configured for branch {} in settings repository {}. Please switch to a branch with remote upstream.", branch, repository);
×
443
      return false;
×
444
    }
445

446
    return true;
×
447
  }
448

449
  private void runGitCommand(Path directory, String... args) {
450

451
    ProcessResult result = runGitCommand(directory, ProcessMode.DEFAULT, args);
6✔
452
    if (!result.isSuccessful()) {
3!
453
      String command = result.getCommand();
×
454
      this.context.requireOnline(command, false);
×
455
      result.failOnError();
×
456
    }
457
  }
1✔
458

459
  private void runGitCommand(Path directory, List<String> args) {
460

461
    runGitCommand(directory, args.toArray(String[]::new));
10✔
462
  }
1✔
463

464
  private String runGitCommandAndGetSingleOutput(String warningOnError, Path directory, String... args) {
465

466
    return runGitCommandAndGetSingleOutput(warningOnError, directory, ProcessMode.DEFAULT_CAPTURE, args);
7✔
467
  }
468

469
  private String runGitCommandAndGetSingleOutput(String warningOnError, Path directory, ProcessMode mode, String... args) {
470
    ProcessErrorHandling errorHandling = ProcessErrorHandling.NONE;
2✔
471
    if (LOG.isDebugEnabled()) {
3!
472
      errorHandling = ProcessErrorHandling.LOG_WARNING;
2✔
473
    }
474
    ProcessResult result = runGitCommand(directory, mode, errorHandling, args);
7✔
475
    if (result.isSuccessful()) {
3!
476
      List<String> out = result.getOut();
3✔
477
      int size = out.size();
3✔
478
      if (size == 1) {
3✔
479
        return out.getFirst();
4✔
480
      } else if (size == 0) {
2!
481
        warningOnError += " - No output received from " + result.getCommand();
6✔
482
      } else {
483
        warningOnError += " - Expected single line of output but received " + size + " lines from " + result.getCommand();
×
484
      }
485
    }
486
    LOG.warn(warningOnError);
3✔
487
    return null;
2✔
488
  }
489

490
  private ProcessResult runGitCommand(Path directory, ProcessMode mode, String... args) {
491

492
    return runGitCommand(directory, mode, ProcessErrorHandling.LOG_WARNING, args);
7✔
493
  }
494

495
  private ProcessResult runGitCommand(Path directory, ProcessMode mode, ProcessErrorHandling errorHandling, String... args) {
496

497
    ProcessContext processContext;
498

499
    if (this.context.isBatchMode()) {
4!
500
      processContext = this.context.newProcess().executable(findGitRequired()).withEnvVar("GIT_TERMINAL_PROMPT", "0").withEnvVar("GCM_INTERACTIVE", "never")
×
501
          .withEnvVar("GIT_ASKPASS", "echo").withEnvVar("SSH_ASKPASS", "echo").errorHandling(errorHandling).directory(directory);
×
502
    } else {
503
      processContext = this.context.newProcess().executable(findGitRequired()).errorHandling(errorHandling).directory(directory);
11✔
504
    }
505

506
    processContext.addArgs(args);
4✔
507
    return processContext.run(mode);
4✔
508
  }
509

510
  @Override
511
  public void saveCurrentCommitId(Path repository, Path trackedCommitIdPath) {
512

513
    if ((repository == null) || (trackedCommitIdPath == null)) {
4!
514
      LOG.warn("Invalid usage of saveCurrentCommitId with null value");
×
515
      return;
×
516
    }
517
    LOG.trace("Saving commit Id of {} into {}", repository, trackedCommitIdPath);
5✔
518
    String currentCommitId = determineCurrentCommitId(repository);
4✔
519
    if (currentCommitId != null) {
2!
520
      try {
521
        Files.writeString(trackedCommitIdPath, currentCommitId);
6✔
522
      } catch (IOException e) {
×
523
        throw new IllegalStateException("Failed to save commit ID", e);
×
524
      }
1✔
525
    }
526
  }
1✔
527

528
  /**
529
   * @param repository the {@link Path} to the git repository.
530
   * @return the current commit ID of the given {@link Path repository}.
531
   */
532
  protected String determineCurrentCommitId(Path repository) {
533
    return runGitCommandAndGetSingleOutput("Failed to get current commit id.", repository, "rev-parse", FILE_HEAD);
×
534
  }
535

536
  @Override
537
  public void commit(Path repository, String message) {
538

539
    runGitCommand(repository, "commit", "-a", "-m", message);
×
540
  }
×
541

542
  @Override
543
  public void tag(Path repository, String tagName, String message) {
544

545
    runGitCommand(repository, "tag", "-a", tagName, "-m", message);
×
546
  }
×
547

548
  @Override
549
  public void push(Path repository) {
550

551
    runGitCommand(repository, "push", "--follow-tags");
×
552
  }
×
553
}
554

555

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