This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 9eb9353417 Fixes #8887 : Git integration no longer runs 
whole-repository git sta… (#8990)
9eb9353417 is described below

commit 9eb93534172b539abcf524009833bbfe2b869bb5
Author: Bart Maertens <[email protected]>
AuthorDate: Tue Oct 20 15:03:14 2026 +0200

    Fixes #8887 : Git integration no longer runs whole-repository git sta… 
(#8990)
    
    * Fixes #8887 : Git integration no longer runs whole-repository git status 
on the UI thread on project open and editor clicks
    
    * Fixes #6216 : Limit the explorer's git status to the project folder when 
the repository is in a parent folder
    
    The commit and Git perspectives keep the status of the whole repository, 
and only refresh on a project switch when they are showing.
---
 .../org/apache/hop/git/GitCommitPerspective.java   |   5 +-
 .../main/java/org/apache/hop/git/GitGuiPlugin.java | 219 ++++++++++++++++++---
 .../java/org/apache/hop/git/GitPerspective.java    |  10 +-
 .../main/java/org/apache/hop/git/model/UIGit.java  | 148 +++++++++++---
 .../apache/hop/git/GitGuiPluginFileStatusTest.java | 195 ++++++++++++++++++
 .../java/org/apache/hop/git/model/UIGitTest.java   | 120 +++++++++++
 .../perspective/explorer/ExplorerPerspective.java  |   2 +-
 .../explorer/IExplorerRefreshListener.java         |  11 ++
 8 files changed, 650 insertions(+), 60 deletions(-)

diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
index 5a83685ab3..bb0a674716 100644
--- 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
+++ 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
@@ -1184,9 +1184,8 @@ public class GitCommitPerspective implements 
IHopPerspective {
         unstagedRootItem = createRootItem(UNSTAGED_LABEL);
         untrackedRootItem = createRootItem(UNTRACKED_LABEL);
 
-        // Reload changes files
-        GitGuiPlugin.getInstance().refreshChangedFiles();
-        Map<String, UIFile> filesToCommit = 
GitGuiPlugin.getInstance().getChangedFiles();
+        // Reload changes files, of the whole repository: not only of the 
project folder
+        Map<String, UIFile> filesToCommit = 
GitGuiPlugin.getInstance().getRepositoryChangedFiles();
 
         GitResource resource = GitResource.getInstance();
         for (String fileName : filesToCommit.keySet()) {
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
index c250c88d04..1ee3dbe178 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
@@ -18,6 +18,7 @@
 
 package org.apache.hop.git;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -27,7 +28,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
+import java.util.Objects;
 import java.util.function.Predicate;
 import lombok.Getter;
 import org.apache.commons.vfs2.FileObject;
@@ -81,6 +82,7 @@ import org.eclipse.swt.graphics.Color;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Menu;
 import org.eclipse.swt.widgets.MenuItem;
 import org.eclipse.swt.widgets.Shell;
@@ -131,6 +133,31 @@ public class GitGuiPlugin
 
   @Getter private Map<String, String> ignoredFiles;
 
+  /**
+   * File systems store modification times in whole seconds, FAT even in two: 
a file saved just
+   * after a status can carry a time from before it.
+   */
+  private static final long MODIFICATION_TIME_MARGIN_MS = 2000L;
+
+  /** When the last status of the whole repository started. */
+  private long lastStatusTime;
+
+  /** {@link UIGit#getStateStamp()} when the last status of the whole 
repository started. */
+  private String lastStatusStamp;
+
+  /** When the status of a single file was last read, by absolute filename. */
+  private final Map<String, Long> fileStatusTimes = new HashMap<>();
+
+  /** Set while the UI event that opened the repository is still running, see 
below. */
+  private boolean statusCurrentForThisEvent;
+
+  /**
+   * The project folder relative to the work tree when the repository was 
found in a parent folder,
+   * null when the project is the whole repository. The explorer only shows 
the project folder, so
+   * its status is limited to it.
+   */
+  private String statusPath;
+
   private static GitGuiPlugin fallback;
 
   public static GitGuiPlugin getInstance() {
@@ -962,6 +989,7 @@ public class GitGuiPlugin
       }
       git = null;
     }
+    statusPath = null;
     setBranchLabel(null);
 
     if (config.isEnabled()) {
@@ -969,7 +997,21 @@ public class GitGuiPlugin
         if (folder != null) {
           git = new UIGit();
           git.openRepo(HopVfs.getFilename(folder));
-          setBranchLabel(git.getBranch());
+          String projectPath =
+              
relativeToRepository(HopVfs.getFilename(HopVfs.getFileObject(rootFolder)));
+          if (!Utils.isEmpty(projectPath) && git.isIgnored(projectPath)) {
+            // A project in the build output of another repository, for 
example: every file in it
+            // is ignored, and each status of that repository costs seconds.
+            LogChannel.UI.logBasic(
+                "Git repository " + git.getDirectory() + " ignores " + 
rootFolder + ", not used");
+            git.closeRepo();
+            git = null;
+          } else {
+            // A project in a sub folder of the repository, like the 
integration test projects of
+            // Apache Hop: a status of that folder takes milliseconds, of the 
repository seconds.
+            statusPath = Utils.isEmpty(projectPath) ? null : projectPath;
+            setBranchLabel(git.getBranch());
+          }
         }
       } catch (Exception e) {
         // This is not a git project...
@@ -977,19 +1019,23 @@ public class GitGuiPlugin
         LogChannel.UI.logBasic("No git project found in " + rootFolder);
       }
     }
-    refreshChangedFiles();
+    readChangedFiles();
+    keepStatusForThisEvent();
     registerAsGuiPluginObjects();
     enableButtons();
 
-    // Refresh Git perspectives when a project is activated
+    // Refresh the Git perspectives that are showing. The others refresh when 
they are activated:
+    // they need a status of the whole repository, which a project in a sub 
folder doesn't.
     GitPerspective gitPerspective = GitPerspective.getInstance();
-    if (gitPerspective != null && gitPerspective.isInitialized()) {
+    if (gitPerspective != null && gitPerspective.isActive()) {
       gitPerspective.refresh(false);
     }
     GitCommitPerspective gitCommitPerspective = 
GitCommitPerspective.getInstance();
     if (gitCommitPerspective != null && gitCommitPerspective.isInitialized()) {
       gitCommitPerspective.retrieveState();
-      gitCommitPerspective.refresh();
+      if (gitCommitPerspective.isActive()) {
+        gitCommitPerspective.refresh();
+      }
     }
   }
 
@@ -1057,27 +1103,87 @@ public class GitGuiPlugin
   }
 
   /* package*/ void refreshChangedFiles() {
-    changedFiles = new HashMap<>();
-    ignoredFiles = new HashMap<>();
+    if (statusCurrentForThisEvent) {
+      return;
+    }
+    readChangedFiles();
+  }
+
+  /**
+   * Read the staged, unstaged and ignored files of the project folder with 
one git status. That is
+   * the whole repository unless the repository was found in a parent folder.
+   */
+  private void readChangedFiles() {
+    long startTime = System.currentTimeMillis();
+    String stamp = git != null ? git.getStateStamp() : null;
+    Map<String, UIFile> newChangedFiles = new HashMap<>();
+    Map<String, String> newIgnoredFiles = new HashMap<>();
 
     if (git != null) {
-      // List the staged and unstaged files...
-      //
-      List<UIFile> files = new ArrayList<>(git.getStagedFiles());
-      files.addAll(git.getUnstagedFiles());
+      UIGit.FileStatus status = git.getFileStatus(statusPath);
+      addFileStatus(status, newChangedFiles, newIgnoredFiles);
+      setBranchLabel(git.getBranch());
+    }
+    changedFiles = newChangedFiles;
+    ignoredFiles = newIgnoredFiles;
+    lastStatusTime = startTime;
+    lastStatusStamp = stamp;
+    fileStatusTimes.clear();
+  }
 
-      for (UIFile file : files) {
-        String path = getAbsoluteFilename(git.getDirectory(), file.getName());
-        changedFiles.put(path, file);
-      }
+  /**
+   * The staged and unstaged files of the whole repository, which is what a 
commit works with. When
+   * the repository was found in a parent folder {@code getChangedFiles()} 
only holds the files of
+   * the project folder, and this costs a status of the whole repository.
+   */
+  public Map<String, UIFile> getRepositoryChangedFiles() {
+    refreshChangedFiles();
+    if (git == null || statusPath == null) {
+      return changedFiles;
+    }
+    Map<String, UIFile> files = new HashMap<>();
+    addFileStatus(git.getFileStatus(null), files, new HashMap<>());
+    return files;
+  }
 
-      Set<String> ignored = git.getIgnored(null);
-      for (String file : ignored) {
-        String path = getAbsoluteFilename(git.getDirectory(), file);
-        ignoredFiles.put(path, file);
-      }
-      setBranchLabel(git.getBranch());
+  private void addFileStatus(
+      UIGit.FileStatus status, Map<String, UIFile> changed, Map<String, 
String> ignored) {
+    // Staged first: a file with unstaged changes on top of staged ones shows 
as unstaged
+    List<UIFile> files = new ArrayList<>(status.staged());
+    files.addAll(status.unstaged());
+    for (UIFile file : files) {
+      changed.put(getAbsoluteFilename(git.getDirectory(), file.getName()), 
file);
+    }
+    for (String file : status.ignored()) {
+      ignored.put(getAbsoluteFilename(git.getDirectory(), file), file);
+    }
+  }
+
+  /**
+   * Let the rest of the UI event that opened a repository reuse the status 
just read. A project
+   * switch asks for it three times in a row: in {@link #rootChanged}, in the 
commit perspective and
+   * when the explorer tree is rebuilt. On a large repository each status 
costs about a second.
+   */
+  private void keepStatusForThisEvent() {
+    Display display = Display.getCurrent();
+    if (display == null) {
+      return;
     }
+    statusCurrentForThisEvent = true;
+    display.asyncExec(() -> statusCurrentForThisEvent = false);
+  }
+
+  /** The path of a file relative to the work tree, null when it is outside 
the repository. */
+  private String relativeToRepository(String filename) {
+    String base = getAbsoluteFilename(git.getDirectory());
+    String path = getAbsoluteFilename(filename);
+    if (base == null || path == null) {
+      return null;
+    }
+    if (path.equals(base)) {
+      return "";
+    }
+    return path.startsWith(base + "/") ? path.substring(base.length() + 1) : 
null;
   }
 
   @Override
@@ -1087,6 +1193,75 @@ public class GitGuiPlugin
     enableButtons();
   }
 
+  /**
+   * Update the git state of the one file whose tree item is repainted. An 
editor asks for this on
+   * every click, so the file is only looked at again when it changed after 
the last status, and
+   * then with a status of that file alone.
+   */
+  @Override
+  public void beforeFileRefresh(String filename) {
+    if (refreshFileStatus(filename)) {
+      enableButtons();
+    }
+  }
+
+  /**
+   * Read the git state of one file again when it changed after it was last 
read, or of the whole
+   * repository when git changed its index or HEAD since the last status.
+   *
+   * @return true when the status of the file was read
+   */
+  /* package */ boolean refreshFileStatus(String filename) {
+    if (git == null || changedFiles == null) {
+      return false;
+    }
+    String path = getAbsoluteFilename(filename);
+    String relativePath = relativeToRepository(path);
+    if (relativePath == null || relativePath.isEmpty()) {
+      return false;
+    }
+    if (!Objects.equals(lastStatusStamp, git.getStateStamp())) {
+      // A git add, commit or checkout outside Hop GUI, in its terminal for 
example: the index or
+      // HEAD changed, which can change the state of any file
+      readChangedFiles();
+      return true;
+    }
+    long checked = fileStatusTimes.getOrDefault(path, lastStatusTime);
+    long lastModified = lastModified(filename);
+    if (lastModified > 0 && lastModified + MODIFICATION_TIME_MARGIN_MS < 
checked) {
+      return false;
+    }
+    long startTime = System.currentTimeMillis();
+    UIGit.FileStatus status = git.getFileStatus(relativePath);
+    changedFiles.remove(path);
+    ignoredFiles.remove(path);
+    addFileStatus(status, changedFiles, ignoredFiles);
+    fileStatusTimes.put(path, startTime);
+    return true;
+  }
+
+  @VisibleForTesting
+  void setGit(UIGit git) {
+    this.git = git;
+  }
+
+  /** The repository of a project in the root folder, which can be a sub 
folder of it. */
+  @VisibleForTesting
+  void setGit(UIGit git, String rootFolder) {
+    this.git = git;
+    String projectPath = relativeToRepository(rootFolder);
+    statusPath = Utils.isEmpty(projectPath) ? null : projectPath;
+  }
+
+  /** The modification time of a file, 0 when it can't be read, for example a 
deleted file. */
+  private static long lastModified(String filename) {
+    try (FileObject fileObject = HopVfs.getFileObject(filename)) {
+      return fileObject.exists() ? 
fileObject.getContent().getLastModifiedTime() : 0L;
+    } catch (Exception e) {
+      return 0L;
+    }
+  }
+
   @Override
   public void fileSelected() {
     enableButtons();
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
index 1113944603..726ec99be2 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
@@ -22,7 +22,6 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
 import java.util.Timer;
 import java.util.TimerTask;
 import org.apache.hop.core.Const;
@@ -224,6 +223,9 @@ public class GitPerspective implements IHopPerspective {
   private boolean showAllRef = true;
   private Timer fetchAutomaticTimer;
 
+  /** The uncommitted changes in the repository, counted on refresh and not on 
every search. */
+  private int uncommittedChanges;
+
   public GitPerspective() {
     if (HopGui.peekInstance() == null) {
       testFallback = this;
@@ -2126,6 +2128,7 @@ public class GitPerspective implements IHopPerspective {
         }
       }
 
+      uncommittedChanges = 
GitGuiPlugin.getInstance().getRepositoryChangedFiles().size();
       refreshRef(uiGit.getGit());
       refreshHistory(Constants.HEAD);
       updateGui();
@@ -2302,13 +2305,12 @@ public class GitPerspective implements IHopPerspective {
           wHistoryTable.removeAll();
 
           // Check if there are changes in the working directory
-          Map<String, UIFile> filesToCommit = 
GitGuiPlugin.getInstance().getChangedFiles();
-          if (filesToCommit != null && !filesToCommit.isEmpty()) {
+          if (uncommittedChanges > 0) {
             TableItem item = new TableItem(wHistoryTable, SWT.NONE);
             item.setText(
                 1,
                 BaseMessages.getString(
-                    PKG, "GitPerspective.History.UncommittedChanges.Label", 
filesToCommit.size()));
+                    PKG, "GitPerspective.History.UncommittedChanges.Label", 
uncommittedChanges));
             item.setForeground(GuiResource.getInstance().getColorGray());
           }
 
diff --git a/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java
index e67e4c0a77..dc1578ea44 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java
@@ -112,6 +112,7 @@ import org.eclipse.jgit.treewalk.CanonicalTreeParser;
 import org.eclipse.jgit.treewalk.EmptyTreeIterator;
 import org.eclipse.jgit.treewalk.FileTreeIterator;
 import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.WorkingTreeIterator;
 import org.eclipse.jgit.treewalk.filter.PathFilter;
 import org.eclipse.jgit.treewalk.filter.TreeFilter;
 import org.eclipse.jgit.util.FileUtils;
@@ -569,49 +570,109 @@ public class UIGit extends VCS {
   // Safe: the stack trace goes to the local stderr only, never to a remote 
client
   @SuppressWarnings("java:S4507")
   public List<UIFile> getUnstagedFiles(String path) {
-    List<UIFile> files = new ArrayList<>();
     Status status = null;
     try {
-      String normalizedPath = normalizePathForJGit(path);
-      StatusCommand statusCommand = git.status();
-      if (normalizedPath != null && !".".equals(normalizedPath)) {
-        statusCommand = statusCommand.addPath(normalizedPath);
-      }
-
-      status = statusCommand.call();
+      status = statusOf(path);
     } catch (Exception e) {
       e.printStackTrace();
-      return files;
+      return new ArrayList<>();
     }
-    Set<String> ignored = getCaseInsensitiveIgnored(status.getUntracked());
-    status.getUntracked().stream()
-        .filter(name -> !ignored.contains(name))
-        .forEach(name -> files.add(new UIFile(name, ChangeType.ADD, false)));
-    status.getConflicting().forEach(name -> files.add(new UIFile(name, 
ChangeType.MODIFY, false)));
-    // Changed in the working tree but not staged: "git add" is what stages 
these
-    status.getModified().forEach(name -> files.add(new UIFile(name, 
ChangeType.MODIFY, false)));
-    status.getMissing().forEach(name -> files.add(new UIFile(name, 
ChangeType.DELETE, false)));
-    return files;
+    return unstagedFiles(status, 
getCaseInsensitiveIgnored(status.getUntracked()));
   }
 
   // Safe: the stack trace goes to the local stderr only, never to a remote 
client
   @SuppressWarnings("java:S4507")
   public List<UIFile> getStagedFiles() {
-    List<UIFile> files = new ArrayList<>();
     Status status = null;
     try {
       status = git.status().call();
     } catch (Exception e) {
       e.printStackTrace();
-      return files;
+      return new ArrayList<>();
+    }
+    return stagedFiles(status);
+  }
+
+  /** The staged, unstaged and ignored files that one "git status" reports. */
+  public record FileStatus(List<UIFile> staged, List<UIFile> unstaged, 
Set<String> ignored) {}
+
+  /**
+   * The staged, unstaged and ignored files from a single status. {@link 
#getStagedFiles()}, {@link
+   * #getUnstagedFiles()} and {@link #getIgnored(String)} each walk the 
working tree again, which
+   * takes close to a second on a repository the size of Apache Hop.
+   *
+   * @param path a file or folder relative to the work tree to limit the 
status to, null for all
+   * @return the files, empty when the status could not be read
+   */
+  public FileStatus getFileStatus(String path) {
+    try {
+      Status status = statusOf(path);
+      Set<String> caseIgnored = 
getCaseInsensitiveIgnored(status.getUntracked());
+      return new FileStatus(
+          stagedFiles(status),
+          unstagedFiles(status, caseIgnored),
+          ignoredFiles(status, caseIgnored));
+    } catch (Exception e) {
+      // Not only GitAPIException: a repository that can no longer be read 
fails with an unchecked
+      // JGitInternalException, and this runs on every click in an editor
+      LogChannel.UI.logError("Error getting the git status", e);
+      return new FileStatus(new ArrayList<>(), new ArrayList<>(), new 
HashSet<>());
+    }
+  }
+
+  /**
+   * Changes whenever the index changes or HEAD moves, for example after a 
"git add" or "git commit"
+   * in a terminal. Those leave the files in the working tree as they are.
+   *
+   * @return the stamp, empty when the repository can't be read
+   */
+  public String getStateStamp() {
+    try {
+      Repository repository = git.getRepository();
+      File index = repository.getIndexFile();
+      ObjectId head = repository.resolve(Constants.HEAD);
+      return index.lastModified() + "/" + index.length() + "/" + (head == null 
? "" : head.name());
+    } catch (Exception e) {
+      return "";
+    }
+  }
+
+  private Status statusOf(String path) throws GitAPIException {
+    String normalizedPath = normalizePathForJGit(path);
+    StatusCommand statusCommand = git.status();
+    if (normalizedPath != null && !".".equals(normalizedPath)) {
+      statusCommand = statusCommand.addPath(normalizedPath);
     }
-    // Only what is in the index: working tree changes are reported by 
getUnstagedFiles()
+    return statusCommand.call();
+  }
+
+  /** Only what is in the index: working tree changes are reported by the 
unstaged files. */
+  private static List<UIFile> stagedFiles(Status status) {
+    List<UIFile> files = new ArrayList<>();
     status.getAdded().forEach(name -> files.add(new UIFile(name, 
ChangeType.ADD, true)));
     status.getChanged().forEach(name -> files.add(new UIFile(name, 
ChangeType.MODIFY, true)));
     status.getRemoved().forEach(name -> files.add(new UIFile(name, 
ChangeType.DELETE, true)));
     return files;
   }
 
+  private static List<UIFile> unstagedFiles(Status status, Set<String> 
caseIgnored) {
+    List<UIFile> files = new ArrayList<>();
+    status.getUntracked().stream()
+        .filter(name -> !caseIgnored.contains(name))
+        .forEach(name -> files.add(new UIFile(name, ChangeType.ADD, false)));
+    status.getConflicting().forEach(name -> files.add(new UIFile(name, 
ChangeType.MODIFY, false)));
+    // Changed in the working tree but not staged: "git add" is what stages 
these
+    status.getModified().forEach(name -> files.add(new UIFile(name, 
ChangeType.MODIFY, false)));
+    status.getMissing().forEach(name -> files.add(new UIFile(name, 
ChangeType.DELETE, false)));
+    return files;
+  }
+
+  private static Set<String> ignoredFiles(Status status, Set<String> 
caseIgnored) {
+    Set<String> ignored = new HashSet<>(status.getIgnoredNotInIndex());
+    ignored.addAll(caseIgnored);
+    return ignored;
+  }
+
   // Safe: the stack trace goes to the local stderr only, never to a remote 
client
   @SuppressWarnings("java:S4507")
   public List<UIFile> getStagedFiles(String oldCommitId, String newCommitId) {
@@ -1805,21 +1866,48 @@ public class UIGit extends VCS {
 
   public Set<String> getIgnored(String path) {
     try {
-      String normalizedPath = normalizePathForJGit(path);
-      StatusCommand statusCommand = git.status();
-      if (normalizedPath != null && !".".equals(normalizedPath)) {
-        statusCommand = statusCommand.addPath(normalizedPath);
-      }
-      Status status = statusCommand.call();
-      Set<String> ignored = new HashSet<>(status.getIgnoredNotInIndex());
-      ignored.addAll(getCaseInsensitiveIgnored(status.getUntracked()));
-      return ignored;
+      Status status = statusOf(path);
+      return ignoredFiles(status, 
getCaseInsensitiveIgnored(status.getUntracked()));
     } catch (GitAPIException e) {
       LogChannel.UI.logError("Error getting list of files ignored by git", e);
       return new HashSet<>();
     }
   }
 
+  /**
+   * Whether git ignores a file or folder, or one of the folders above it. 
Only the folders on the
+   * way to the path are read, so this is fast in a repository of any size.
+   *
+   * @param path the path relative to the work tree, with forward slashes
+   */
+  public boolean isIgnored(String path) {
+    String normalizedPath = normalizePathForJGit(path);
+    if (normalizedPath == null || normalizedPath.isEmpty() || 
".".equals(normalizedPath)) {
+      return false;
+    }
+    Repository repository = git.getRepository();
+    if (CaseInsensitiveIgnores.appliesTo(repository)
+        && new CaseInsensitiveIgnores(repository).isIgnored(normalizedPath)) {
+      return true;
+    }
+    try (TreeWalk walk = new TreeWalk(repository)) {
+      walk.addTree(new FileTreeIterator(repository));
+      walk.setFilter(PathFilter.create(normalizedPath));
+      while (walk.next()) {
+        if (walk.getTree(0, WorkingTreeIterator.class).isEntryIgnored()) {
+          return true;
+        }
+        // What lies below the path itself doesn't matter
+        if (walk.isSubtree() && !normalizedPath.equals(walk.getPathString())) {
+          walk.enterSubtree();
+        }
+      }
+    } catch (IOException e) {
+      LogChannel.UI.logError("Error checking whether git ignores '" + 
normalizedPath + "'", e);
+    }
+    return false;
+  }
+
   /**
    * The files JGit reports as untracked but git itself ignores. JGit matches 
ignore rules case
    * sensitively whatever core.ignorecase says, so on a case insensitive file 
system a rule like
diff --git 
a/plugins/misc/git/src/test/java/org/apache/hop/git/GitGuiPluginFileStatusTest.java
 
b/plugins/misc/git/src/test/java/org/apache/hop/git/GitGuiPluginFileStatusTest.java
new file mode 100644
index 0000000000..10a9e13e52
--- /dev/null
+++ 
b/plugins/misc/git/src/test/java/org/apache/hop/git/GitGuiPluginFileStatusTest.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.git;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.spy;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.Map;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.git.model.UIFile;
+import org.apache.hop.git.model.UIGit;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** An editor click must not cost a git status of the whole repository. */
+class GitGuiPluginFileStatusTest {
+
+  @TempDir Path repositoryFolder;
+
+  private Path pipeline;
+  private String filename;
+  private UIGit uiGit;
+  private GitGuiPlugin plugin;
+
+  @BeforeAll
+  static void setUpBeforeClass() throws Exception {
+    HopClientEnvironment.init();
+  }
+
+  @BeforeEach
+  void setUp() throws Exception {
+    pipeline = repositoryFolder.resolve("pipeline.hpl");
+    Files.writeString(pipeline, "<pipeline/>", StandardCharsets.UTF_8);
+    try (Git git = Git.init().setDirectory(repositoryFolder.toFile()).call()) {
+      git.add().addFilepattern("pipeline.hpl").call();
+      commit(git, "initial commit");
+    }
+    filename = pipeline.toString();
+
+    uiGit = new UIGit();
+    uiGit.openRepo(repositoryFolder.toString());
+
+    // The branch label lives in the status toolbar of Hop GUI, which a unit 
test doesn't have
+    plugin = spy(new GitGuiPlugin());
+    doNothing().when(plugin).setBranchLabel(any());
+    plugin.setGit(uiGit);
+    plugin.refreshChangedFiles();
+  }
+
+  @AfterEach
+  void tearDown() {
+    uiGit.closeRepo();
+  }
+
+  private static void commit(Git git, String message) throws Exception {
+    git.commit()
+        .setMessage(message)
+        .setAuthor("Hop", "[email protected]")
+        .setCommitter("Hop", "[email protected]")
+        .setSign(false)
+        .call();
+  }
+
+  /** Only git itself changed something, in the index or HEAD: the file is as 
old as it was. */
+  private void makeFileOld() throws Exception {
+    Files.setLastModifiedTime(pipeline, 
FileTime.fromMillis(System.currentTimeMillis() - 60_000L));
+  }
+
+  @Test
+  void aFileUnchangedSinceTheStatusIsNotReadAgain() throws Exception {
+    makeFileOld();
+
+    assertFalse(plugin.refreshFileStatus(filename));
+    assertNull(plugin.getChangedFiles().get(filename));
+  }
+
+  @Test
+  void aFileSavedAfterTheStatusIsReadAgain() throws Exception {
+    Files.writeString(pipeline, "<pipeline><info/></pipeline>", 
StandardCharsets.UTF_8);
+
+    assertTrue(plugin.refreshFileStatus(filename));
+    UIFile file = plugin.getChangedFiles().get(filename);
+    assertNotNull(file);
+    assertEquals(ChangeType.MODIFY, file.getChangeType());
+    assertFalse(file.isStaged());
+  }
+
+  @Test
+  void aGitAddOutsideHopGuiIsPickedUp() throws Exception {
+    Files.writeString(pipeline, "<pipeline><info/></pipeline>", 
StandardCharsets.UTF_8);
+    plugin.refreshChangedFiles();
+    assertFalse(plugin.getChangedFiles().get(filename).isStaged());
+
+    makeFileOld();
+    try (Git git = Git.open(repositoryFolder.toFile())) {
+      git.add().addFilepattern("pipeline.hpl").call();
+    }
+
+    assertTrue(plugin.refreshFileStatus(filename));
+    assertTrue(plugin.getChangedFiles().get(filename).isStaged());
+  }
+
+  @Test
+  void aGitCommitOutsideHopGuiIsPickedUp() throws Exception {
+    Files.writeString(pipeline, "<pipeline><info/></pipeline>", 
StandardCharsets.UTF_8);
+    try (Git git = Git.open(repositoryFolder.toFile())) {
+      git.add().addFilepattern("pipeline.hpl").call();
+    }
+    plugin.refreshChangedFiles();
+    assertNotNull(plugin.getChangedFiles().get(filename));
+
+    makeFileOld();
+    try (Git git = Git.open(repositoryFolder.toFile())) {
+      commit(git, "second commit");
+    }
+
+    assertTrue(plugin.refreshFileStatus(filename));
+    assertNull(plugin.getChangedFiles().get(filename));
+  }
+
+  @Test
+  void aFileOutsideTheRepositoryIsLeftAlone(@TempDir Path otherFolder) throws 
Exception {
+    Path other = otherFolder.resolve("other.hpl");
+    Files.writeString(other, "<pipeline/>", StandardCharsets.UTF_8);
+
+    assertFalse(plugin.refreshFileStatus(other.toString()));
+  }
+
+  /**
+   * A project in a sub folder of the repository, like the integration test 
projects of Apache Hop:
+   * the explorer only needs the status of the project folder, a commit that 
of the repository.
+   */
+  @Test
+  void aProjectInASubFolderOnlyReadsTheStatusOfThatFolder() throws Exception {
+    Path project = 
Files.createDirectories(repositoryFolder.resolve("project"));
+    String inProject = project.resolve("in-project.hpl").toString();
+    Files.writeString(Path.of(inProject), "<pipeline/>", 
StandardCharsets.UTF_8);
+    Files.writeString(pipeline, "<pipeline><info/></pipeline>", 
StandardCharsets.UTF_8);
+
+    plugin.setGit(uiGit, project.toString());
+    plugin.refreshChangedFiles();
+
+    assertNotNull(plugin.getChangedFiles().get(inProject));
+    assertNull(plugin.getChangedFiles().get(filename));
+
+    Map<String, UIFile> repositoryFiles = plugin.getRepositoryChangedFiles();
+    assertNotNull(repositoryFiles.get(inProject));
+    assertNotNull(repositoryFiles.get(filename));
+    assertNull(plugin.getChangedFiles().get(filename));
+  }
+
+  @Test
+  void aProjectInTheRootOfTheRepositoryReadsTheWholeRepository() throws 
Exception {
+    Files.writeString(pipeline, "<pipeline><info/></pipeline>", 
StandardCharsets.UTF_8);
+
+    plugin.setGit(uiGit, repositoryFolder.toString());
+    plugin.refreshChangedFiles();
+
+    assertNotNull(plugin.getChangedFiles().get(filename));
+    // No second status: what the explorer has covers the whole repository 
already
+    Map<String, UIFile> repositoryFiles = plugin.getRepositoryChangedFiles();
+    assertSame(plugin.getChangedFiles(), repositoryFiles);
+  }
+}
diff --git 
a/plugins/misc/git/src/test/java/org/apache/hop/git/model/UIGitTest.java 
b/plugins/misc/git/src/test/java/org/apache/hop/git/model/UIGitTest.java
index 338eccb096..63d4670742 100644
--- a/plugins/misc/git/src/test/java/org/apache/hop/git/model/UIGitTest.java
+++ b/plugins/misc/git/src/test/java/org/apache/hop/git/model/UIGitTest.java
@@ -31,6 +31,7 @@ import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
@@ -45,12 +46,15 @@ import java.util.Set;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.io.IOUtils;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
 import org.apache.hop.git.model.revision.ObjectRevision;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.RemoteAddCommand;
 import org.eclipse.jgit.api.Status;
+import org.eclipse.jgit.api.StatusCommand;
+import org.eclipse.jgit.api.errors.JGitInternalException;
 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.junit.RepositoryTestCase;
@@ -66,6 +70,7 @@ import org.eclipse.jgit.transport.RemoteConfig;
 import org.eclipse.jgit.transport.URIish;
 import org.junit.After;
 import org.junit.Before;
+import org.junit.BeforeClass;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
 
@@ -74,6 +79,12 @@ public class UIGitTest extends RepositoryTestCase {
   private UIGit uiGit;
   Repository db2;
 
+  /** UIGit logs what it can't read to the Hop log, which needs the log store. 
*/
+  @BeforeClass
+  public static void setUpLogStore() {
+    HopLogStore.init();
+  }
+
   @Override
   @Before
   public void setUp() throws Exception {
@@ -908,6 +919,115 @@ public class UIGitTest extends RepositoryTestCase {
     return uiGit.getUnstagedFiles().stream().map(UIFile::getName).toList();
   }
 
+  private static List<String> sortedNames(List<UIFile> files) {
+    return files.stream().map(UIFile::getName).sorted().toList();
+  }
+
+  @Test
+  public void testFileStatusReportsWhatTheSeparateCallsReport() throws 
Exception {
+    initialCommit();
+
+    writeTrashFile(".gitignore", "*.log\n");
+    writeTrashFile("Staged.txt", "staged");
+    uiGit.add("Staged.txt");
+    writeTrashFile("Test.txt", "Changed");
+    writeTrashFile("New.txt", "New file");
+    writeTrashFile("run.log", "log");
+
+    UIGit.FileStatus status = uiGit.getFileStatus(null);
+
+    assertEquals(sortedNames(uiGit.getStagedFiles()), 
sortedNames(status.staged()));
+    assertEquals(sortedNames(uiGit.getUnstagedFiles()), 
sortedNames(status.unstaged()));
+    assertEquals(uiGit.getIgnored(null), status.ignored());
+
+    assertEquals(List.of("Staged.txt"), sortedNames(status.staged()));
+    assertTrue(sortedNames(status.unstaged()).containsAll(List.of("New.txt", 
"Test.txt")));
+    assertTrue(status.ignored().contains("run.log"));
+  }
+
+  @Test
+  public void testFileStatusOfOneFile() throws Exception {
+    initialCommit();
+
+    writeTrashFile("Test.txt", "Changed");
+    writeTrashFile("New.txt", "New file");
+
+    UIGit.FileStatus status = uiGit.getFileStatus("Test.txt");
+
+    assertEquals(List.of("Test.txt"), sortedNames(status.unstaged()));
+    assertTrue(status.staged().isEmpty());
+  }
+
+  @Test
+  public void testFileStatusOfARepositoryThatCanNoLongerBeReadIsEmpty() throws 
Exception {
+    // JGit reports an unreadable repository with an unchecked exception. The 
status is read on
+    // every click in an editor, so it must not reach the UI.
+    Git unreadable = mock(Git.class);
+    StatusCommand statusCommand = mock(StatusCommand.class);
+    doReturn(statusCommand).when(unreadable).status();
+    doThrow(new JGitInternalException("Unable to read the 
index")).when(statusCommand).call();
+    uiGit.setGit(unreadable);
+
+    UIGit.FileStatus status = uiGit.getFileStatus(null);
+
+    assertTrue(status.staged().isEmpty());
+    assertTrue(status.unstaged().isEmpty());
+    assertTrue(status.ignored().isEmpty());
+  }
+
+  @Test
+  public void testStateStampChangesWithTheIndexAndHead() throws Exception {
+    initialCommit();
+    String committed = uiGit.getStateStamp();
+
+    writeTrashFile("New.txt", "New file");
+    assertEquals(committed, uiGit.getStateStamp());
+
+    git.add().addFilepattern("New.txt").call();
+    String added = uiGit.getStateStamp();
+    assertNotEquals(committed, added);
+
+    git.commit().setMessage("second commit").call();
+    assertNotEquals(added, uiGit.getStateStamp());
+  }
+
+  @Test
+  public void testIsIgnoredLooksAtTheFoldersAbove() throws Exception {
+    initialCommit();
+
+    // A project in the build output of the repository, the way the samples 
project of a Hop
+    // client built from source sits in the Hop repository
+    writeTrashFile(".gitignore", "target/\n");
+    
writeTrashFile("assemblies/client/target/hop/config/projects/samples/a.hpl", 
"<pipeline/>");
+    writeTrashFile("src/b.hpl", "<pipeline/>");
+
+    
assertTrue(uiGit.isIgnored("assemblies/client/target/hop/config/projects/samples"));
+    assertTrue(uiGit.isIgnored("assemblies/client/target"));
+    assertFalse(uiGit.isIgnored("assemblies/client"));
+    assertFalse(uiGit.isIgnored("src"));
+    assertFalse(uiGit.isIgnored(""));
+  }
+
+  @Test
+  public void testIsIgnoredWithoutRegardToCase() throws Exception {
+    initialCommit();
+
+    writeTrashFile(".gitignore", "target/\n");
+    writeTrashFile("Target/projects/samples/a.hpl", "<pipeline/>");
+
+    assertFalse(uiGit.isIgnored("Target/projects/samples"));
+
+    db.getConfig()
+        .setBoolean(
+            ConfigConstants.CONFIG_CORE_SECTION,
+            null,
+            CaseInsensitiveIgnores.CONFIG_KEY_IGNORECASE,
+            true);
+    db.getConfig().save();
+
+    assertTrue(uiGit.isIgnored("Target/projects/samples"));
+  }
+
   @Test
   public void testCreateBranchFromTag() throws Exception {
     RevCommit tagged = initialCommit();
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 3c65c7d456..ff28fb707a 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -5076,7 +5076,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       TreeItem item = findTreeItem(filename);
       if (item != null) {
         for (IExplorerRefreshListener listener : refreshListeners) {
-          listener.beforeRefresh();
+          listener.beforeFileRefresh(filename);
         }
         callPaintListeners(tree, item, filename, fileTypeHandler.getName());
       }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/IExplorerRefreshListener.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/IExplorerRefreshListener.java
index 00a637967b..fa4e3f7237 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/IExplorerRefreshListener.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/IExplorerRefreshListener.java
@@ -20,4 +20,15 @@ package org.apache.hop.ui.hopgui.perspective.explorer;
 
 public interface IExplorerRefreshListener {
   void beforeRefresh();
+
+  /**
+   * Called before the tree item of one file is repainted, which an open 
editor asks for on nearly
+   * every click. Update only what this file needs; the default does a full 
{@link
+   * #beforeRefresh()}.
+   *
+   * @param filename the absolute filename
+   */
+  default void beforeFileRefresh(String filename) {
+    beforeRefresh();
+  }
 }

Reply via email to