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();
+ }
}