diff --git a/src/Commands/QueryIgnoredDirectories.cs b/src/Commands/QueryIgnoredDirectories.cs
new file mode 100644
index 000000000..e32068ac2
--- /dev/null
+++ b/src/Commands/QueryIgnoredDirectories.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace SourceGit.Commands
+{
+ ///
+ /// Queries the directories that are entirely ignored by git (they match an ignore rule and contain no tracked
+ /// files). Paths are relative to the repository root, use '/' as separator and end with '/'.
+ ///
+ public class QueryIgnoredDirectories : Command
+ {
+ public QueryIgnoredDirectories(string repo)
+ {
+ WorkingDirectory = repo;
+ Context = repo;
+ RaiseError = false;
+ Args = "--no-optional-locks status --porcelain -z --ignored=matching --untracked-files=normal --ignore-submodules=all --no-renames";
+ }
+
+ public async Task> GetResultAsync()
+ {
+ var outs = new HashSet(StringComparer.Ordinal);
+ var rs = await ReadToEndAsync().ConfigureAwait(false);
+ if (!rs.IsSuccess)
+ return outs;
+
+ var entries = rs.StdOut.Split('\0', StringSplitOptions.RemoveEmptyEntries);
+ foreach (var entry in entries)
+ {
+ if (entry.Length > 4 && entry.StartsWith("!! ", StringComparison.Ordinal) && entry.EndsWith('/'))
+ outs.Add(entry.Substring(3));
+ }
+
+ return outs;
+ }
+ }
+}
diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs
index 66e246bc0..7ca84a563 100644
--- a/src/Models/Watcher.cs
+++ b/src/Models/Watcher.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
@@ -226,6 +227,15 @@ private void Tick(object sender)
if (refreshCommits)
_repo.RefreshCommits();
+
+ var oldUpdateIgnoredDirs = Interlocked.Exchange(ref _updateIgnoredDirs, -1);
+ if (oldUpdateIgnoredDirs > 0)
+ {
+ if (now > oldUpdateIgnoredDirs)
+ RefreshIgnoredDirs();
+ else
+ Interlocked.CompareExchange(ref _updateIgnoredDirs, oldUpdateIgnoredDirs, -1);
+ }
}
private void OnRepositoryChanged(object o, FileSystemEventArgs e)
@@ -235,12 +245,15 @@ private void OnRepositoryChanged(object o, FileSystemEventArgs e)
var name = e.Name.Replace('\\', '/').TrimEnd('/');
if (name.EndsWith("/.git", StringComparison.Ordinal))
+ {
+ _submoduleDirs.Clear();
return;
+ }
if (name.StartsWith(".git/", StringComparison.Ordinal))
HandleGitDirFileChanged(name.Substring(5));
else
- HandleWorkingCopyFileChanged(name, e.FullPath);
+ HandleWorkingCopyFileChanged(name, e.FullPath, e.ChangeType);
}
///
@@ -274,11 +287,16 @@ private void OnWorkingCopyChanged(object o, FileSystemEventArgs e)
var name = e.Name.Replace('\\', '/').TrimEnd('/');
if (name.Equals(".git", StringComparison.Ordinal) ||
- name.StartsWith(".git/", StringComparison.Ordinal) ||
- name.EndsWith("/.git", StringComparison.Ordinal))
+ name.StartsWith(".git/", StringComparison.Ordinal))
return;
- HandleWorkingCopyFileChanged(name, e.FullPath);
+ if (name.EndsWith("/.git", StringComparison.Ordinal))
+ {
+ _submoduleDirs.Clear();
+ return;
+ }
+
+ HandleWorkingCopyFileChanged(name, e.FullPath, e.ChangeType);
}
private void HandleGitDirFileChanged(string name)
@@ -333,26 +351,49 @@ private void HandleGitDirFileChanged(string name)
Interlocked.Exchange(ref _updateTags, desired);
Interlocked.Exchange(ref _updateStashes, desired);
}
- else if (name.StartsWith("objects/", StringComparison.Ordinal) || name.Equals("index", StringComparison.Ordinal))
+ else if (name.Equals("index", StringComparison.Ordinal) || name.Equals("info/exclude", StringComparison.Ordinal))
+ {
+ // Tracked files or ignore rules may have changed, so the known ignored directories are not reliable anymore.
+ InvalidateIgnoredDirs();
+ Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime());
+ }
+ else if (name.StartsWith("objects/", StringComparison.Ordinal))
{
Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime());
}
+ else if (name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/index", StringComparison.Ordinal))
+ {
+ InvalidateIgnoredDirs();
+ }
}
- private void HandleWorkingCopyFileChanged(string name, string fullpath)
+ private void HandleWorkingCopyFileChanged(string name, string fullpath, WatcherChangeTypes changeType)
{
if (name.StartsWith(".vs/", StringComparison.Ordinal))
return;
if (name.Equals(".gitmodules", StringComparison.Ordinal))
{
+ _submoduleDirs.Clear();
+
var desired = DateTime.Now.AddSeconds(1).ToFileTime();
Interlocked.Exchange(ref _updateSubmodules, desired);
Interlocked.Exchange(ref _updateWC, desired);
return;
}
- var dir = Directory.Exists(fullpath) ? fullpath : Path.GetDirectoryName(fullpath);
+ if (IsInIgnoredDir(name))
+ return;
+
+ if (name.Equals(".gitignore", StringComparison.Ordinal) || name.EndsWith("/.gitignore", StringComparison.Ordinal))
+ InvalidateIgnoredDirs();
+
+ // A new directory may be an ignored one (the output folder of the first build, for example).
+ var isDir = Directory.Exists(fullpath);
+ if (isDir && changeType is WatcherChangeTypes.Created or WatcherChangeTypes.Renamed)
+ Interlocked.Exchange(ref _updateIgnoredDirs, DateTime.Now.AddSeconds(1).ToFileTime());
+
+ var dir = isDir ? fullpath : Path.GetDirectoryName(fullpath);
if (IsInSubmodule(dir))
{
Interlocked.Exchange(ref _updateSubmodules, DateTime.Now.AddSeconds(1).ToFileTime());
@@ -367,10 +408,74 @@ private bool IsInSubmodule(string folder)
if (string.IsNullOrEmpty(folder) || folder.Equals(_root, StringComparison.Ordinal))
return false;
- if (File.Exists($"{folder}/.git"))
+ if (_submoduleDirs.TryGetValue(folder, out var cached))
+ return cached;
+
+ var isInSubmodule = File.Exists($"{folder}/.git") || IsInSubmodule(Path.GetDirectoryName(folder));
+ _submoduleDirs.TryAdd(folder, isInSubmodule);
+ return isInSubmodule;
+ }
+
+ ///
+ /// Changes inside a directory that git ignores entirely (for example the output folders of a build) can
+ /// never change the result of `git status`, so there is no need to refresh the working copy for them.
+ /// The ignored directories are queried lazily, and queried again after ignore rules or the index change.
+ ///
+ private bool IsInIgnoredDir(string name)
+ {
+ var ignoredDirs = _ignoredDirs;
+ if (ignoredDirs == null)
+ {
+ // Do NOT postpone a pending query, so that it still runs while files are written continuously.
+ if (Interlocked.Read(ref _updateIgnoredDirs) <= 0)
+ Interlocked.Exchange(ref _updateIgnoredDirs, DateTime.Now.AddSeconds(.5).ToFileTime());
+
+ return false;
+ }
+
+ if (ignoredDirs.Count == 0)
+ return false;
+
+ if (ignoredDirs.Contains($"{name}/"))
return true;
- return IsInSubmodule(Path.GetDirectoryName(folder));
+ var idx = name.IndexOf('/');
+ while (idx > 0)
+ {
+ if (ignoredDirs.Contains(name.Substring(0, idx + 1)))
+ return true;
+
+ idx = name.IndexOf('/', idx + 1);
+ }
+
+ return false;
+ }
+
+ private void InvalidateIgnoredDirs()
+ {
+ lock (_ignoredDirsLock)
+ {
+ _ignoredDirsVersion++;
+ _ignoredDirs = null;
+ }
+ }
+
+ private void RefreshIgnoredDirs()
+ {
+ long version;
+ lock (_ignoredDirsLock)
+ version = _ignoredDirsVersion;
+
+ Task.Run(async () =>
+ {
+ var ignoredDirs = await new Commands.QueryIgnoredDirectories(_root).GetResultAsync().ConfigureAwait(false);
+ lock (_ignoredDirsLock)
+ {
+ // Drop the result if ignore rules or the index have changed in the meantime.
+ if (_ignoredDirsVersion == version)
+ _ignoredDirs = ignoredDirs;
+ }
+ });
}
private readonly IRepository _repo;
@@ -384,5 +489,11 @@ private bool IsInSubmodule(string folder)
private long _updateSubmodules;
private long _updateStashes;
private long _updateTags;
+ private long _updateIgnoredDirs;
+
+ private readonly ConcurrentDictionary _submoduleDirs = new();
+ private readonly Lock _ignoredDirsLock = new();
+ private volatile HashSet _ignoredDirs = null;
+ private long _ignoredDirsVersion;
}
}