All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.netbeans.modules.git.FileStatusCache Maven / Gradle / Ivy

There is a newer version: RELEASE240
Show newest version
/*
 * 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.netbeans.modules.git;

import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.netbeans.libs.git.GitException;
import org.netbeans.libs.git.GitStatus;
import org.netbeans.libs.git.progress.ProgressMonitor;
import org.netbeans.modules.turbo.CacheIndex;
import org.netbeans.modules.versioning.spi.VCSContext;
import org.netbeans.modules.versioning.spi.VersioningSupport;
import org.netbeans.modules.git.FileInformation.Status;
import org.netbeans.modules.git.client.GitClient;
import org.netbeans.modules.git.utils.GitUtils;
import org.openide.filesystems.FileUtil;
import org.openide.util.RequestProcessor;
import org.openide.util.Utilities;

/**
 *
 * @author ondra
 */
public class FileStatusCache {

    public static final String PROP_FILE_STATUS_CHANGED = "status.changed"; // NOI18N

    private final CacheIndex conflictedFiles, modifiedFiles, ignoredFiles;
    private static final Logger LOG = Logger.getLogger("org.netbeans.modules.git.status.cache"); //NOI18N
    private int MAX_COUNT_UPTODATE_FILES = 1024;
    private static final int CACHE_SIZE_WARNING_THRESHOLD = 50000; // log when cache gets too big and steps over this threshold
    private boolean hugeCacheWarningLogged;
    int upToDateAccess = 0;
    private static final int UTD_NOTIFY_NUMBER = 100;
    /**
     * Keeps cached statuses for managed files
     */
    private final Map cachedFiles;
    private final LinkedHashSet upToDateFiles = new LinkedHashSet<>(MAX_COUNT_UPTODATE_FILES);
    private final RequestProcessor rp = new RequestProcessor("Git.cache", 1, true, false);
    private final HashSet nestedRepositories = new HashSet<>(2); // mainly for logging
    private final PropertyChangeSupport listenerSupport = new PropertyChangeSupport(this);

    private static final FileInformation FILE_INFORMATION_UPTODATE = new FileInformation(EnumSet.of(Status.UPTODATE), false);
    private static final FileInformation FILE_INFORMATION_NOTMANAGED = new FileInformation(EnumSet.of(Status.NOTVERSIONED_NOTMANAGED), false);
    private static final FileInformation FILE_INFORMATION_EXCLUDED = new FileInformation(EnumSet.of(Status.NOTVERSIONED_EXCLUDED), false);
    private static final FileInformation FILE_INFORMATION_NEWLOCALLY = new FileInformation(EnumSet.of(Status.NEW_INDEX_WORKING_TREE, Status.NEW_HEAD_WORKING_TREE), false);
    private static final FileInformation FILE_INFORMATION_UNKNOWN = new FileInformation(EnumSet.of(Status.UNKNOWN), false);

    private static final Map SYNC_REPOSITORIES = new WeakHashMap<>(5);
    private final IgnoredFilesHandler ignoredFilesHandler;
    private final RequestProcessor.Task ignoredFilesHandlerTask;
    private static final boolean USE_IGNORE_INDEX = !Boolean.getBoolean("versioning.git.noignoreindex"); //NOI18N
    private final Git git = Git.getInstance();

    public FileStatusCache() {
        cachedFiles = new HashMap<>();
        conflictedFiles = createCacheIndex();
        modifiedFiles = createCacheIndex();
        ignoredFiles = createCacheIndex();
        ignoredFilesHandler = new IgnoredFilesHandler();
        ignoredFilesHandlerTask = rp.create(ignoredFilesHandler);
    }

    /**
     * Fast version of {@link #getStatus(java.io.File)}.
     * @param file
     * @return always returns a not null value
     */
    public FileInformation getStatus (final File file) {
        return getStatus(file, true);
    }

    public void addPropertyChangeListener(PropertyChangeListener listener) {
        listenerSupport.addPropertyChangeListener(listener);
    }

    public void removePropertyChangeListener(PropertyChangeListener listener) {
        listenerSupport.removePropertyChangeListener(listener);
    }

    /**
     * Prepares refresh candidates, sorts them under their repository roots and eventually calls the cache refresh
     * @param files roots to refresh
     */
    public void refreshAllRoots(File... roots) {
        refreshAllRoots(Arrays.asList(roots), GitUtils.NULL_PROGRESS_MONITOR);
    }
    
    /**
     * Prepares refresh candidates, sorts them under their repository roots and eventually calls the cache refresh
     * @param files roots to refresh
     */
    public void refreshAllRoots (final Collection files) {
        refreshAllRoots(files, GitUtils.NULL_PROGRESS_MONITOR);
    }
    
    /**
     * Prepares refresh candidates, sorts them under their repository roots and eventually calls the cache refresh
     * @param files roots to refresh
     * @param pm progress monitor able to cancel the running status scan
     */
    public void refreshAllRoots (final Collection files, ProgressMonitor pm) {
        long startTime = 0;
        if (LOG.isLoggable(Level.FINE)) {
            startTime = System.currentTimeMillis();
            LOG.log(Level.FINE, "refreshAll: starting for {0} files.", files.size()); //NOI18N
        }
        if (files.isEmpty()) {
            return;
        }
        HashMap> rootFiles = new HashMap<>(5);

        for (File file : files) {
            if (pm.isCanceled()) {
                return;
            }
            // go through all files and sort them under repository roots
            file = FileUtil.normalizeFile(file);
            File repository = git.getRepositoryRoot(file);
            File parentFile;
            File parentRepository;
            if (repository == null) {
                // we have an unversioned root, maybe the whole subtree should be removed from cache (VCS owners might have changed)
                continue;
            } else if (repository.equals(file) && (parentFile = file.getParentFile()) != null
                    && (parentRepository = git.getRepositoryRoot(parentFile)) != null) {
                addUnderRoot(rootFiles, parentRepository, file);
            }
            addUnderRoot(rootFiles, repository, file);
        }
        if (LOG.isLoggable(Level.FINE)) {
            LOG.log(Level.FINE, "refreshAll: starting status scan for {0} after {1}", new Object[]{rootFiles.values(), System.currentTimeMillis() - startTime}); //NOI18N
            startTime = System.currentTimeMillis();
        }
        if (!rootFiles.isEmpty()) {
            refreshAllRoots(rootFiles, pm);
        }
        if (LOG.isLoggable(Level.FINE)) {
            LOG.log(Level.FINE, "refreshAll: finishes status scan after {0}", (System.currentTimeMillis() - startTime)); //NOI18N
        }
    }

    /**
     * Refreshes all files under given roots in the cache.
     * @param rootFiles root files to scan sorted under their repository roots
     */
    public void refreshAllRoots (Map> rootFiles) {
        refreshAllRoots(rootFiles, GitUtils.NULL_PROGRESS_MONITOR);
    }

    /**
     * Refreshes all files under given roots in the cache.
     * @param rootFiles root files to scan sorted under their repository roots
     * @param pm progress monitor capable of interrupting the status scan
     */
    public void refreshAllRoots (Map> rootFiles, ProgressMonitor pm) {
        for (Map.Entry> refreshEntry : rootFiles.entrySet()) {
            if (pm.isCanceled()) {
                return;
            }
            File repository = refreshEntry.getKey();
            if (repository == null) {
                continue;
            }
            File syncRepo = getSyncRepository(repository);
            // Synchronize on the repository, refresh should not run concurrently
            synchronized (syncRepo) {
                if (LOG.isLoggable(Level.FINE)) {
                    LOG.log(Level.FINE, "refreshAllRoots() roots: {0}, repositoryRoot: {1} ", new Object[] {refreshEntry.getValue(), repository.getAbsolutePath()}); // NOI18N
                }
                Map interestingFiles;
                GitClient client = null;
                try {
                    // find all files with not up-to-date or ignored status
                    client = git.getClient(repository);
                    interestingFiles = client.getStatus(refreshEntry.getValue().toArray(new File[refreshEntry.getValue().size()]), pm);
                    if (pm.isCanceled()) {
                        return;
                    }
                    for (File root : refreshEntry.getValue()) {
                        // clean all files originally in the cache but now being up-to-date or obsolete (as ignored && deleted)
                        for (File file : listFiles(Collections.singleton(root), EnumSet.complementOf(EnumSet.of(Status.UPTODATE)))) {
                            FileInformation fi = getInfo(file);
                            if (fi == null || fi.containsStatus(Status.UPTODATE)) {
                                LOG.log(Level.WARNING, "refreshAllRoots(): possibly concurrent refresh: {0}:{1}", new Object[] { file, fi });
                                fi = new FileInformation(EnumSet.of(Status.UPTODATE), file.isDirectory());
                                boolean ea = false;
                                assert ea = true;
                                if (ea) {
                                    try {
                                        Thread.sleep(100);
                                    } catch (InterruptedException ex) { }
                                    if (new HashSet<>(Arrays.asList(listFiles(Collections.singleton(file.getParentFile()), EnumSet.complementOf(EnumSet.of(Status.UPTODATE))))).contains(file)) {
                                        LOG.log(Level.WARNING, "refreshAllRoots(): now we have a problem, index seems to be broken", new Object[] { file });
                                    }
                                }
                                continue;
                            }
                            boolean exists = file.exists();
                            File filesOwner = null;
                            boolean correctRepository = true;
                            if (!interestingFiles.containsKey(file) // file no longer has an interesting status
                                    && (fi.containsStatus(Status.NOTVERSIONED_EXCLUDED) && (!exists || // file was ignored and is now deleted
                                    fi.isDirectory() && !GitUtils.isIgnored(file, true)) ||  // folder is now up-to-date (and NOT ignored by Sharability)
                                    !fi.isDirectory() && !fi.containsStatus(Status.NOTVERSIONED_EXCLUDED)) // file is now up-to-date or also ignored by .gitignore
                                    && (correctRepository = !repository.equals(file) && repository.equals(filesOwner = git.getRepositoryRoot(file)))) { // do not remove info for gitlinks or nested repositories
                                LOG.log(Level.FINE, "refreshAllRoots() uninteresting file: {0} {1}", new Object[]{file, fi}); // NOI18N
                                refreshFileStatus(file, FILE_INFORMATION_UNKNOWN); // remove the file from cache
                            }
                            if (!correctRepository) {
                                if (nestedRepositories.add(filesOwner)) {
                                    LOG.log(Level.INFO, "refreshAllRoots: nested repository found: {0} contains {1}", new File[] {repository, filesOwner}); //NOI18N
                                }
                            }
                        }
                    }
                    refreshStatusesBatch(interestingFiles);
                } catch (GitException ex) {
                    LOG.log(Level.INFO, "refreshAllRoots() file: {0} {1} {2} ", new Object[] {repository.getAbsolutePath(), refreshEntry.getValue(), ex.toString()}); //NOI18N
                } finally {
                    if (client != null) {
                        client.release();
                    }
                    if (LOG.isLoggable(Level.FINE)) {
                        LOG.log(Level.FINE, "refreshAllRoots() roots: finished repositoryRoot: {0} ", new Object[] { repository.getAbsolutePath() } ); // NOI18N
                    }
                }
            }
        }
    }

    /**
     * Evaluates if there are any files with the given status under the given roots
     *
     * @param context context to examine
     * @param includeStatus limit returned files to those having one of supplied statuses
     * @return true if there are any files with the given status otherwise false
     */
    public boolean containsFiles (VCSContext context, Set includeStatus, boolean addExcluded) {
        Set roots = context.getRootFiles();
        // check all files underneath the roots
        return containsFiles(roots, includeStatus, addExcluded);
    }

    /**
     * Evaluates if there are any files with the given status under the given roots
     *
     * @param rootFiles context to examine
     * @param includeStatus limit returned files to those having one of supplied statuses
     * @return true if there are any files with the given status otherwise false
     */
    public boolean containsFiles (Set roots, Set includeStatus, boolean addExcluded) {
        Set repositories = GitUtils.getRepositoryRoots(roots);
        // get as deep as possible, so Turbo.readEntry() - which accesses io - gets called the least times
        // in such case we may end up with just access to io - getting the status of indeed modified file
        // the other way around it would check status for all directories along the path
        for (File root : roots) {
            if(containsFilesIntern(getIndexValues(root, includeStatus, repositories), includeStatus, !VersioningSupport.isFlat(root), addExcluded, 1, repositories)) {
                return true;
            }
        }

        // check to roots if they apply to the given status
        if (containsFilesIntern(roots, includeStatus, false, addExcluded, 0, repositories)) {
            return true;
        }
        return false;
    }
        
    /**
     * Lists interesting files that are known to be inside given folders.
     * These are locally and remotely modified and ignored files.
     *
     * @param roots context to examine
     * @return File [] array of interesting files
     */
    public File [] listFiles (File... roots) {
        return listFiles(Arrays.asList(roots), FileInformation.STATUS_ALL);
    }
    
    /**
     * Lists interesting files that are known to be inside given folders.
     * These are locally and remotely modified and ignored files.
     *
     * @param roots context to examine
     * @param includeStatus limit returned files to those having one of supplied statuses
     * @return File [] array of interesting files
     */
    public File [] listFiles (File[] roots, EnumSet includeStatus) {
        return listFiles(Arrays.asList(roots), includeStatus);
    }
    
    /**
     * Lists interesting files that are known to be inside given folders.
     * These are locally and remotely modified and ignored files.
     *
     * @param roots context to examine
     * @param includeStatus limit returned files to those having one of supplied statuses
     * @return File [] array of interesting files
     */
    public File [] listFiles (Collection roots, EnumSet includeStatus) {
        Set set = new HashSet<>();
        Set repositories = GitUtils.getRepositoryRoots(roots);

        // get all files with given status underneath the roots files;
        // do it recusively if root isn't a flat folder
        for (File root : roots) {
            set.addAll(listFilesIntern(getIndexValues(root, includeStatus, repositories), includeStatus, !VersioningSupport.isFlat(root), repositories));
        }
        // check also the root files for status and add them eventually
        set.addAll(listFilesIntern(roots, includeStatus, false, repositories));
        return set.toArray(new File[0]);
    }
    
    /**
     * Returns the cached file information or null if it does not exist in the cache.
     * @param file
     * @return
     */
    private FileInformation getInfo (File file) {
        FileInformation info;
        synchronized (cachedFiles) {
            info = cachedFiles.get(file);
            synchronized (upToDateFiles) {
                if (info == null && upToDateFiles.contains(file)) {
                    addUpToDate(file);
                    info = FILE_INFORMATION_UPTODATE;
                }
            }
        }
        return info;
    }

    /**
     * Sets FI for the given files
     * @param file
     * @param info
     */
    private void setInfo (File file, FileInformation info) {
        synchronized (cachedFiles) {
            cachedFiles.put(file, info);
            if (!hugeCacheWarningLogged && cachedFiles.size() > CACHE_SIZE_WARNING_THRESHOLD) {
                LOG.log(Level.WARNING, "Cache contains too many entries: {0}", (Integer) cachedFiles.size()); //NOI18N
                hugeCacheWarningLogged = true;
            }
            removeUpToDate(file);
        }
    }

    /**
     * Removes the cached value for the given file. Call e.g. if the file becomes up-to-date
     * or uninteresting (no longer existing ignored file).
     * @param file
     */
    private void removeInfo (File file) {
        synchronized (cachedFiles) {
            cachedFiles.remove(file);
            removeUpToDate(file);
        }
    }

    /**
     * Adds an up-to-date file to the cache of UTD files.
     * The cache should have a limited size, so if a threshold is reached, the oldest file is automatically removed.
     * @param file file to add
     */
    private void addUpToDate (File file) {
        synchronized (upToDateFiles) {
            upToDateFiles.remove(file);
            upToDateFiles.add(file); // add the file to the end of the linked collection
            if (upToDateFiles.size() >= MAX_COUNT_UPTODATE_FILES) {
                if (LOG.isLoggable(Level.FINE)) {
                    // trying to find a reasonable limit for uptodate files in cache
                    LOG.log(Level.WARNING, "Cache of uptodate files grows too quickly: {0}", upToDateFiles.size()); //NOI18N
                    MAX_COUNT_UPTODATE_FILES <<= 1;
                    assert false;
                } else {
                    // removing 1/8 eldest entries
                    Iterator it = upToDateFiles.iterator();
                    int toDelete = MAX_COUNT_UPTODATE_FILES >> 3;
                    for (int i = 0; i < toDelete && it.hasNext(); ++i) {
                        it.next();
                        it.remove();
                    }
                }
            }
        }
    }

    private boolean removeUpToDate (File file) {
        synchronized (upToDateFiles) {
            return upToDateFiles.remove(file);
        }
    }

    /**
     * TODO: go through the logic once more, it seems very very complex
     * Fast version of {@link #getStatus(java.io.File)}.
     * @param file
     * @param seenInUI false value means the file/folder is not visible in UI and thus cannot trigger initial git status scan
     * @return always returns a not null value
     */
    private FileInformation getStatus (final File file, boolean seenInUI) {
        FileInformation info = getInfo(file); // cached value
        LOG.log(Level.FINER, "getCachedStatus for file {0}: {1}", new Object[] {file, info}); //NOI18N
        boolean triggerGitScan = false;
        boolean addAsExcluded = false;
        if (info == null) {
            if (git.isManaged(file)) {
                // ping repository scan, this means it has not yet been scanned
                // but scan only files/folders visible in IDE
                triggerGitScan = seenInUI;
                // fast ignore-test
                info = checkForIgnoredFile(file);
                if (info == null) {
                    // info could have changed in the previous call
                    info = getInfo(file);
                }
                if (file.isDirectory()) {
                    setInfo(file, info = (info != null && info.containsStatus(Status.NOTVERSIONED_EXCLUDED)
                            ? new FileInformation(EnumSet.of(Status.NOTVERSIONED_EXCLUDED), true)
                            : new FileInformation(EnumSet.of(Status.UPTODATE), true)));
                } else {
                    if (info == null || info.containsStatus(Status.UPTODATE)) {
                        info = FILE_INFORMATION_UPTODATE;
                        addUpToDate(file);
                        // XXX delete later
                        if (++upToDateAccess > UTD_NOTIFY_NUMBER) {
                            upToDateAccess = 0;
                            if (LOG.isLoggable(Level.FINE)) {
                                synchronized (upToDateFiles) {
                                    LOG.log(Level.FINE, "Another {0} U2D files added: {1}", new Object[] {UTD_NOTIFY_NUMBER, upToDateFiles}); //NOI18N
                                }
                            }
                        }
                    } else if (info.containsStatus(Status.NOTVERSIONED_EXCLUDED)) {
                        addAsExcluded = true;
                    }
                }
            } else {
                // unmanaged files
                info = file.isDirectory() ? new FileInformation(EnumSet.of(Status.NOTVERSIONED_NOTMANAGED), true) : FILE_INFORMATION_NOTMANAGED;
            }
            LOG.log(Level.FINER, "getCachedStatus: default for file {0}: {1}", new Object[] {file, info}); //NOI18N
        } else {
            // an u-t-d file may be actually ignored. This needs to be checked since we skip ignored folders in the status scan
            // so ignored files appear as up-to-date after the scan finishes
            if (info.containsStatus(Status.UPTODATE) && checkForIgnoredFile(file) != null) {
                info = FILE_INFORMATION_EXCLUDED;
                addAsExcluded = true;
            }
            triggerGitScan = seenInUI && !info.seenInUI();
        }
        if (addAsExcluded) {
            // add ignored file to cache
            rp.post(new Runnable() {
                @Override
                public void run() {
                    synchronized (FileStatusCache.this) {
                        FileInformation info = getInfo(file);
                        if (info == null || info.containsStatus(Status.UPTODATE)) {
                            refreshFileStatus(file, file.isDirectory() 
                                    ? new FileInformation(EnumSet.of(Status.NOTVERSIONED_EXCLUDED), true)
                                    : FILE_INFORMATION_EXCLUDED);
                        }
                    }
                }
            });
        }
        if (triggerGitScan) {
            info.setSeenInUI(true); // next time this file/folder will not trigger the git scan
            git.getVCSInterceptor().pingRepositoryRootFor(file);
        }
        return info;
    }

    /**
     * Fires an event into IDE
     * @param file
     * @param oldInfo
     * @param newInfo
     */
    private void fireFileStatusChanged(File file, FileInformation oldInfo, FileInformation newInfo) {
        fireFileStatusChanged(new ChangedEvent(file, oldInfo, newInfo));
    }

    private void fireFileStatusChanged (ChangedEvent event) {
        listenerSupport.firePropertyChange(PROP_FILE_STATUS_CHANGED, null, event);
    }
    
    /**
     * Updates cache with scanned information for the given file
     * @param file
     * @param fi
     * @param interestingFiles
     * @param alwaysFireEvent
     */
    private void refreshFileStatus(File file, FileInformation fi) {
        if(file == null || fi == null) return;
        FileInformation current;
        boolean fireEvent = true;
        synchronized (this) {
            file = FileUtil.normalizeFile(file);
            current = getInfo(file);
            fi = checkForIgnore(fi, current, file);
            if (equivalent(fi, current)) {
                // no need to fire an event
                if (Utilities.isWindows() || Utilities.isMac()) {
                    // but for these we need to update keys in cache because of renames AAA.java -> aaa.java
                    fireEvent = false;
                } else {
                    return;
                }
            }
            boolean addToIndex = updateCachedValue(fi, file);
            updateIndex(file, fi, addToIndex);
        }
        if (fireEvent) {
            fireFileStatusChanged(file, current, fi);
        }
    }
    
    private void refreshStatusesBatch (Map interestingFiles) {
        List events = new ArrayList<>(interestingFiles.size());
        synchronized (this) {
            List indexUpdates = new ArrayList<>(interestingFiles.size());
            for (Map.Entry interestingEntry : interestingFiles.entrySet()) {
                // put the file's FI into the cache
                File file = interestingEntry.getKey();
                FileInformation fi = new FileInformation(interestingEntry.getValue());
                LOG.log(Level.FINE, "refreshAllRoots() file status: {0} {1}", new Object[] {file.getAbsolutePath(), fi}); // NOI18N
                
                FileInformation current;
                boolean fireEvent = true;
                current = getInfo(file);
                fi = checkForIgnore(fi, current, file);
                if (equivalent(fi, current)) {
                    // no need to fire an event
                    if (Utilities.isWindows() || Utilities.isMac()) {
                        // but for these we need to update keys in cache because of renames AAA.java -> aaa.java
                        fireEvent = false;
                    } else {
                        continue;
                    }
                }
                boolean addToIndex = updateCachedValue(fi, file);
                indexUpdates.add(new IndexUpdateItem(file, fi, addToIndex));
                if (fireEvent) {
                    events.add(new ChangedEvent(file, current, fi));
                }
            }
            updateIndexBatch(indexUpdates);
        }
        for (ChangedEvent event : events) {
            fireFileStatusChanged(event);
        }
    }

    private FileInformation checkForIgnore (FileInformation fi, FileInformation current, File file) {
        if ((equivalent(FILE_INFORMATION_NEWLOCALLY, fi)
                || // ugly piece of code, call sharability for U2D files only when toggling between ignored and U2D, otherwise SQ is called for EVERY U2D file
                (current != null && fi.getStatus().contains(Status.UPTODATE) && current.getStatus().contains(Status.NOTVERSIONED_EXCLUDED))) && (GitUtils.isIgnored(file, true) || isParentIgnored(file))) {
            // file lies under an excluded parent
            LOG.log(Level.FINE, "refreshFileStatus() file: {0} was LocallyNew but is NotSharable", file.getAbsolutePath()); // NOI18N
            fi = file.isDirectory() ? new FileInformation(EnumSet.of(Status.NOTVERSIONED_EXCLUDED), true) : FILE_INFORMATION_EXCLUDED;
        }
        return fi;
    }

    private boolean updateCachedValue (FileInformation fi, File file) {
        boolean addToIndex = false;
        if (fi.getStatus().equals(EnumSet.of(Status.UNKNOWN))) {
            removeInfo(file);
        } else if (fi.getStatus().equals(EnumSet.of(Status.UPTODATE)) && file.isFile()) {
            removeInfo(file);
            addUpToDate(file);
        } else {
            setInfo(file, fi);
            addToIndex = true;
        }
        return addToIndex;
    }

    /**
     * Two FileInformation objects are equivalent if their status contants are equal AND they both reperesent a file (or
     * both represent a directory) AND Entries they cache, if they can be compared, are equal.
     *
     * @param other object to compare to
     * @return true if status constants of both object are equal, false otherwise
     */
    private static boolean equivalent (FileInformation main, FileInformation other) {
        boolean retval;
        if (other != null && main.getStatus().equals(other.getStatus()) && main.isDirectory() == other.isDirectory()) {
            retval = main.getStatusText().equals(other.getStatusText());
        } else {
            retval = false;
        }
        return retval;
    }

    private boolean containsFilesIntern (Set indexRoots, Set includeStatus, boolean recursively, boolean addExcluded, int depth, Set repositories) {
        if(indexRoots == null || indexRoots.isEmpty()) {
            return false;
        }
        // get as deep as possible, so Turbo.readEntry() - which accesses io - gets called the least times
        // in such case we may end up with just access to io - getting the status of indeed modified file
        // the other way around it would check status for all directories along the path
        for (File root : indexRoots) {
            Set indexValues = getIndexValues(root, includeStatus, repositories);
            if(recursively && containsFilesIntern(indexValues, includeStatus, recursively, addExcluded, depth + 1, repositories)) {
                return true;
            }
        }
        for (File root : indexRoots) {
            FileInformation fi = getInfo(root);
            if (fi != null && fi.containsStatus(includeStatus) && (addExcluded || depth == 0
                    || !GitModuleConfig.getDefault().isExcludedFromCommit(root.getAbsolutePath()))) {
                return true;
            }
        }
        return false;
    }

    private Set listFilesIntern (Collection roots, EnumSet includeStatus, boolean recursively, Set queriedRepositories) {
        if(roots == null || roots.isEmpty()) {
            return Collections.emptySet();
        }
        Set ret = new HashSet<>();
        for (File root : roots) {
            if(recursively) {
                ret.addAll(listFilesIntern(getIndexValues(root, includeStatus, queriedRepositories), includeStatus, recursively, queriedRepositories));
            }
            FileInformation fi = getInfo(root);
            if (fi == null || !fi.containsStatus(includeStatus)) {
                continue;
            }
            ret.add(root);
        }
        return ret;
    }

    private static CacheIndex createCacheIndex() {
        return new CacheIndex() {
            @Override
            protected boolean isManaged (File file) {
                return Git.getInstance().isManaged(file);
            }
        };
    }

    private Set getIndexValues (File root, Set includeStatus, Set queriedRepositories) {
        File[] modified = new File[0];
        File[] ignored = new File[0];
        if (includeStatus.contains(Status.NOTVERSIONED_EXCLUDED)) {
            ignored = ignoredFiles.get(root);
        }
        if (FileInformation.STATUS_LOCAL_CHANGES.clone().removeAll(includeStatus)) {
            if (includeStatus.equals(EnumSet.of(Status.IN_CONFLICT))) {
                modified = conflictedFiles.get(root);
            } else {
                modified = modifiedFiles.get(root);
            }
        }
        Set values = new HashSet<>(Arrays.asList(ignored));
        values.addAll(Arrays.asList(modified));
        if (queriedRepositories != null) {
            values = checkBelongToRepository(values, queriedRepositories);
        }
        return values;
    }
    
    private Set checkBelongToRepository (Set files, Set repositories) {
        for (Iterator it = files.iterator(); it.hasNext(); ) {
            File f = it.next();
            File repo = git.getRepositoryRoot(f);
            if (!f.equals(repo) // git link
                    && !repositories.contains(repo)) { // from a subrepo
                it.remove();
            }
        }
        return files;
    }

    private void updateIndex(File file, FileInformation fi, boolean addToIndex) {
        File parent = file.getParentFile();
        if (parent != null) {
            Set conflicted = new HashSet<>(Arrays.asList(conflictedFiles.get(parent)));
            Set modified = new HashSet<>(Arrays.asList(modifiedFiles.get(parent)));
            Set ignored = new HashSet<>(Arrays.asList(ignoredFiles.get(parent)));
            boolean modifiedChange = modified.remove(file);
            boolean conflictedChange = conflicted.remove(file);
            boolean ignoredChange = USE_IGNORE_INDEX && ignored.remove(file);
            if (addToIndex) {
                if (fi.containsStatus(Status.NOTVERSIONED_EXCLUDED)) {
                    ignoredChange |= USE_IGNORE_INDEX && ignored.add(file);
                } else {
                    modifiedChange |= modified.add(file);
                    if (fi.containsStatus(Status.IN_CONFLICT)) {
                        conflictedChange |= conflicted.add(file);
                    }
                }
            }
            if (modifiedChange) {
                modifiedFiles.add(parent, modified);
            }
            if (conflictedChange) {
                conflictedFiles.add(parent, conflicted);
            }
            if (ignoredChange) {
                ignoredFiles.add(parent, ignored);
            }
        }
    }

    private void updateIndexBatch (List updates) {
        Map> modifications = new HashMap<>();
        Map> conflicts = new HashMap<>();
        Map> ignores = new HashMap<>();
        for (IndexUpdateItem item : updates) {
            File file = item.getFile();
            File parent = file.getParentFile();
            if (parent != null) {
                Set modified = get(modifications, parent, modifiedFiles);
                Set conflicted = get(conflicts, parent, conflictedFiles);
                modified.remove(file);
                conflicted.remove(file);
                Set ignored = null;
                if (USE_IGNORE_INDEX) {
                    ignored = get(ignores, parent, ignoredFiles);
                    ignored.remove(file);
                }
                if (item.isAdd()) {
                    FileInformation fi = item.getInfo();
                    if (fi.containsStatus(Status.NOTVERSIONED_EXCLUDED)) {
                        if (USE_IGNORE_INDEX) {
                            ignored.add(file);
                        }
                    } else {
                        modified.add(file);
                        if (fi.containsStatus(Status.IN_CONFLICT)) {
                            conflicted.add(file);
                        }
                    }
                }
            }
        }
        for (Map.Entry> e : modifications.entrySet()) {
            modifiedFiles.add(e.getKey(), e.getValue());
        }
        for (Map.Entry> e : conflicts.entrySet()) {
            conflictedFiles.add(e.getKey(), e.getValue());
        }
        for (Map.Entry> e : ignores.entrySet()) {
            ignoredFiles.add(e.getKey(), e.getValue());
        }
    }

    private Set get (Map> cached, File parent, CacheIndex index) {
        Set modified = cached.get(parent);
        if (modified == null) {
            modified = new HashSet<>(Arrays.asList(index.get(parent)));
            cached.put(parent, modified);
        }
        return modified;
    }

    /**
     * Fast (can be run from AWT) version of {@link #handleIgnoredFiles(Set)}, tests a file if it's ignored, but never runs a SharebilityQuery.
     * If the file is not recognized as ignored, runs {@link #handleIgnoredFiles(Set)}.
     * @param file
     * @return {@link #FILE_INFORMATION_EXCLUDED} if the file is recognized as ignored (but not through a SharebilityQuery), null otherwise
     */
    private FileInformation checkForIgnoredFile (File file) {
        FileInformation fi = null;
        if (file.getParentFile() != null && isParentIgnored(file)) {
            fi = FILE_INFORMATION_EXCLUDED;
        } else {
            // run the full test with the SQ
            handleIgnoredFiles(Collections.singleton(file));
        }
        return fi;
    }

    /**
     * Checks if given files are ignored, also calls a SharebilityQuery. Cached status for ignored files is eventually refreshed.
     * Can be run from AWT, in that case it switches to a background thread.
     * @param files set of files to be ignore-tested.
     */
    private void handleIgnoredFiles(final Set files) {
        boolean changed;
        synchronized (ignoredFilesHandler.toHandle) {
            changed = ignoredFilesHandler.toHandle.addAll(files);
        }
        if (changed) {
            ignoredFilesHandlerTask.schedule(0);
        }
    }

    private boolean isParentIgnored (File file) {
        File parentFile = file.getParentFile();
        boolean parentIgnored = getStatus(parentFile, false).containsStatus(Status.NOTVERSIONED_EXCLUDED);
        // but the parent may be another repository root ignored by the parent repository
        if (parentFile.equals(git.getRepositoryRoot(parentFile))) {
            parentIgnored = false;
        }
        return parentIgnored;
    }
    
    private class IgnoredFilesHandler implements Runnable {
        
        private final Set toHandle = new LinkedHashSet<>();
        
        @Override
        public void run() {
            File f;
            while ((f = getNextFile()) != null) {
                if (GitUtils.isIgnored(f, true)) {
                    // refresh status for this file
                    boolean isDirectory = f.isDirectory();
                    boolean exists = f.exists();
                    if (!exists) {
                        // remove from cache
                        refreshFileStatus(f, FILE_INFORMATION_UNKNOWN);
                    } else {
                        // add to cache as ignored
                        refreshFileStatus(f, isDirectory ? new FileInformation(EnumSet.of(Status.NOTVERSIONED_EXCLUDED), true) : FILE_INFORMATION_EXCLUDED);
                    }
                }
            }
        }

        private File getNextFile() {
            File nextFile = null;
            synchronized (toHandle) {
                Iterator it = toHandle.iterator();
                if (it.hasNext()) {
                    nextFile = it.next();
                    it.remove();
                }
            }
            return nextFile;
        }
        
    }

    private File getSyncRepository (File repository) {
        File cachedRepository = git.getRepositoryRoot(repository);
        if (repository.equals(cachedRepository)) {
            repository = cachedRepository;
        }
        synchronized (SYNC_REPOSITORIES) {
            cachedRepository = SYNC_REPOSITORIES.get(repository);
            if (cachedRepository == null) {
                // create a NEW instance, otherwise we'll lock also git commands that sync on repository root, too
                cachedRepository = new File(repository.getParentFile(), repository.getName());
                SYNC_REPOSITORIES.put(cachedRepository, cachedRepository);
            }
        }
        return cachedRepository;
    }

    private void addUnderRoot (HashMap> rootFiles, File repository, File file) {
        // file is a gitlink inside another repository, we need to refresh also the file's status explicitely
        Collection filesUnderRoot = rootFiles.get(repository);
        if (filesUnderRoot == null) {
            filesUnderRoot = new HashSet<>();
            rootFiles.put(repository, filesUnderRoot);
        }
        GitUtils.prepareRootFiles(repository, filesUnderRoot, file);
    }

    public static class ChangedEvent {

        private final File file;
        private final FileInformation oldInfo;
        private final FileInformation newInfo;

        public ChangedEvent(File file, FileInformation oldInfo, FileInformation newInfo) {
            this.file = file;
            this.oldInfo = oldInfo;
            this.newInfo = newInfo;
        }

        public File getFile() {
            return file;
        }

        public FileInformation getOldInfo() {
            return oldInfo;
        }

        public FileInformation getNewInfo() {
            return newInfo;
        }
    }

    private static class IndexUpdateItem {

        private final File file;
        private final FileInformation fi;
        private final boolean add;

        public IndexUpdateItem (File file, FileInformation fi, boolean toBeAdded) {
            this.file = file;
            this.fi = fi;
            this.add = toBeAdded;
        }

        public File getFile () {
            return file;
        }

        public FileInformation getInfo () {
            return fi;
        }

        public boolean isAdd () {
            return add;
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy