Show the window before slow location probes and wrap git.exe for commit, diff, and merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 16:31:56 +02:00
parent a3c54bbb03
commit 9efb306979
42 changed files with 3184 additions and 77 deletions

View File

@@ -19,5 +19,6 @@
<ProjectReference Include="..\..\src\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\..\src\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,171 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Windows;
namespace Explorer.Application.Tests;
public class GitCommandIntegrationTests : IDisposable
{
private readonly string _root;
private readonly WindowsGitStatusProvider _git;
public GitCommandIntegrationTests()
{
_root = Path.Combine(Path.GetTempPath(), "ew-git", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_git = new WindowsGitStatusProvider(new UiPreferencesStore(new GitTestEnv(_root)));
}
public void Dispose()
{
try { Directory.Delete(_root, true); } catch { /* ignore */ }
}
[Fact]
public async Task Diff_stage_commit_and_merge_resolution()
{
if (GitLocator.Find(null) is null)
{
return;
}
var repo = Path.Combine(_root, "repo");
Directory.CreateDirectory(repo);
await Git(repo, "init", "-b", "main").ConfigureAwait(true);
await Git(repo, "config", "user.email", "test@example.com").ConfigureAwait(true);
await Git(repo, "config", "user.name", "Test").ConfigureAwait(true);
await Git(repo, "config", "core.autocrlf", "false").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\n");
await Git(repo, "add", "note.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "base").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\nedited\n");
var status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
var unstaged = Assert.Single(status.Changes, c => c.State == GitChangeState.Unstaged);
var diff = await _git.DiffAsync(repo, unstaged).ConfigureAwait(true);
Assert.Null(diff.Error);
Assert.Contains(diff.Lines, l => l.Kind == GitDiffLineKind.Added && l.Text.Contains("edited"));
var staged = await _git.StageAsync(repo, "note.txt").ConfigureAwait(true);
Assert.True(staged.Succeeded, staged.DisplayMessage);
var committed = await _git.CommitAsync(repo, "edit", ["note.txt"]).ConfigureAwait(true);
Assert.True(committed.Succeeded, committed.DisplayMessage);
await Git(repo, "checkout", "-b", "topic").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\nedited\ntopic\n");
await Git(repo, "add", "note.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "topic").ConfigureAwait(true);
await Git(repo, "checkout", "main").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\nedited\nmain\n");
await Git(repo, "add", "note.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "main").ConfigureAwait(true);
var merge = await GitExpectFail(repo, "merge", "topic").ConfigureAwait(true);
Assert.False(string.IsNullOrWhiteSpace(merge));
status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.Equal(GitOperationKind.Merge, status.Operation);
Assert.Contains(status.Changes, c => c.State == GitChangeState.Unmerged);
var ours = await _git.CheckoutConflictAsync(repo, "note.txt", GitConflictSide.Ours).ConfigureAwait(true);
Assert.True(ours.Succeeded, ours.DisplayMessage);
status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.False(status.HasUnmerged);
var continued = await _git.ContinueOperationAsync(repo).ConfigureAwait(true);
Assert.True(continued.Succeeded, continued.DisplayMessage);
status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.Equal(GitOperationKind.None, status.Operation);
Assert.True(status.WorkingTreeClean);
}
[Fact]
public async Task Abort_restores_the_pre_merge_tree()
{
if (GitLocator.Find(null) is null)
{
return;
}
var repo = Path.Combine(_root, "abort");
Directory.CreateDirectory(repo);
await Git(repo, "init", "-b", "main").ConfigureAwait(true);
await Git(repo, "config", "user.email", "test@example.com").ConfigureAwait(true);
await Git(repo, "config", "user.name", "Test").ConfigureAwait(true);
await Git(repo, "config", "core.autocrlf", "false").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "a.txt"), "one\n");
await Git(repo, "add", "a.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "one").ConfigureAwait(true);
await Git(repo, "checkout", "-b", "other").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "a.txt"), "two\n");
await Git(repo, "add", "a.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "two").ConfigureAwait(true);
await Git(repo, "checkout", "main").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "a.txt"), "three\n");
await Git(repo, "add", "a.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "three").ConfigureAwait(true);
await GitExpectFail(repo, "merge", "other").ConfigureAwait(true);
var aborted = await _git.AbortOperationAsync(repo).ConfigureAwait(true);
Assert.True(aborted.Succeeded, aborted.DisplayMessage);
var status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.Equal(GitOperationKind.None, status.Operation);
Assert.Equal("three\n", File.ReadAllText(Path.Combine(repo, "a.txt")).Replace("\r\n", "\n"));
}
private static async Task Git(string repo, params string[] args)
{
var result = await RunGit(repo, args).ConfigureAwait(true);
Assert.True(result.Exit == 0, result.Text);
}
private static async Task<string> GitExpectFail(string repo, params string[] args)
{
var result = await RunGit(repo, args).ConfigureAwait(true);
Assert.NotEqual(0, result.Exit);
return result.Text;
}
private static async Task<(int Exit, string Text)> RunGit(string repo, string[] args)
{
var git = GitLocator.Find(null) ?? throw new InvalidOperationException("git.exe missing");
using var process = new System.Diagnostics.Process();
process.StartInfo.FileName = git;
process.StartInfo.WorkingDirectory = repo;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(true);
var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(true);
await process.WaitForExitAsync().ConfigureAwait(true);
return (process.ExitCode, stdout + stderr);
}
}
file sealed class GitTestEnv : IAppEnvironment
{
public GitTestEnv(string dir)
{
DataDirectory = Path.Combine(dir, "data");
Directory.CreateDirectory(DataDirectory);
DatabasePath = Path.Combine(DataDirectory, "index.db");
LogDirectory = Path.Combine(DataDirectory, "logs");
Directory.CreateDirectory(LogDirectory);
}
public string DataDirectory { get; }
public string DatabasePath { get; }
public string LogDirectory { get; }
}

View File

@@ -0,0 +1,114 @@
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Application.Tests;
public class GitCommitPlannerTests
{
private static readonly HashSet<string> None = new(StringComparer.OrdinalIgnoreCase);
[Fact]
public void Rejects_an_empty_message()
{
var plan = GitCommitPlanner.Create(
" ",
[Change("src/App.cs")],
None,
None,
operationInProgress: false);
Assert.False(plan.CanCommit);
Assert.Equal("Enter a commit message.", plan.Error);
}
[Fact]
public void Rejects_merge_or_rebase_in_progress()
{
var plan = GitCommitPlanner.Create(
"wip",
[Change("src/App.cs")],
None,
None,
operationInProgress: true);
Assert.False(plan.CanCommit);
Assert.Contains("merge or rebase", plan.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Skips_unmerged_hydration_and_directories()
{
var plan = GitCommitPlanner.Create(
"save",
[
Change("conflict.txt", GitChangeState.Unmerged, 'U', 'U'),
Change("cloud.bin"),
Change("src"),
],
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "cloud.bin" },
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "src" },
operationInProgress: false);
Assert.False(plan.CanCommit);
Assert.Equal(["conflict.txt"], plan.SkippedUnmerged);
Assert.Equal(["cloud.bin"], plan.SkippedHydration);
Assert.Equal(["src"], plan.SkippedDirectories);
Assert.Contains("online-only", plan.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Dedupes_staged_and_unstaged_of_the_same_path()
{
var plan = GitCommitPlanner.Create(
"both",
[
Change("src/Both.cs", GitChangeState.Staged, 'M', '.'),
Change("src/Both.cs", GitChangeState.Unstaged, '.', 'M'),
],
None,
None,
operationInProgress: false);
Assert.True(plan.CanCommit);
Assert.Equal(["src/Both.cs"], plan.Paths);
}
[Fact]
public void Commits_files_and_deleted_paths_while_skipping_the_rest()
{
var plan = GitCommitPlanner.Create(
"mixed",
[
Change("keep.cs"),
Change("gone.cs", GitChangeState.Unstaged, '.', 'D'),
Change("conflict.txt", GitChangeState.Unmerged, 'U', 'U'),
Change("cloud.bin"),
Change("src"),
],
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "cloud.bin" },
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "src", "gone.cs" },
operationInProgress: false);
Assert.True(plan.CanCommit);
Assert.Equal(["keep.cs", "gone.cs"], plan.Paths);
Assert.Equal(["conflict.txt"], plan.SkippedUnmerged);
Assert.Equal(["cloud.bin"], plan.SkippedHydration);
Assert.Equal(["src"], plan.SkippedDirectories);
}
[Fact]
public void Requires_at_least_one_file()
{
var plan = GitCommitPlanner.Create("msg", [], None, None, operationInProgress: false);
Assert.False(plan.CanCommit);
Assert.Equal("Select at least one file to commit.", plan.Error);
}
private static GitChange Change(
string path,
GitChangeState state = GitChangeState.Unstaged,
char index = '.',
char workTree = 'M')
=> new()
{
Path = path,
State = state,
Index = index,
WorkTree = workTree
};
}

View File

@@ -0,0 +1,86 @@
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Application.Tests;
public class GitDiffPlannerTests
{
[Fact]
public void Staged_diff_does_not_read_the_working_tree()
{
var request = GitDiffPlanner.Create(new GitChange
{
Path = "src/App.cs",
State = GitChangeState.Staged,
Index = 'M',
WorkTree = '.'
});
Assert.Null(request.Error);
Assert.False(request.NeedsWorkingTree);
Assert.Contains("--cached", request.Arguments);
Assert.Contains("src/App.cs", request.Arguments);
}
[Fact]
public void Untracked_diff_uses_no_index_against_dev_null()
{
var request = GitDiffPlanner.Create(new GitChange
{
Path = "new.txt",
State = GitChangeState.Untracked,
Index = '?',
WorkTree = '?'
});
Assert.True(request.NeedsWorkingTree);
Assert.Contains("--no-index", request.Arguments);
Assert.Contains("/dev/null", request.Arguments);
Assert.Contains("new.txt", request.Arguments);
}
[Fact]
public void Deleted_unstaged_diff_does_not_need_the_working_tree()
{
var request = GitDiffPlanner.Create(new GitChange
{
Path = "gone.cs",
State = GitChangeState.Unstaged,
Index = '.',
WorkTree = 'D'
});
Assert.False(request.NeedsWorkingTree);
Assert.DoesNotContain("--cached", request.Arguments);
}
}
public class GitDiffParserTests
{
[Fact]
public void Classifies_unified_diff_lines()
{
var diff = GitDiffParser.Parse("""
diff --git a/src/App.cs b/src/App.cs
index 111..222 100644
--- a/src/App.cs
+++ b/src/App.cs
@@ -1,3 +1,4 @@
keep
-old
+new
still
""", "src/App.cs");
Assert.Equal("src/App.cs", diff.Title);
Assert.False(diff.IsBinary);
Assert.Equal(GitDiffLineKind.Meta, diff.Lines[0].Kind);
Assert.Equal(GitDiffLineKind.Hunk, diff.Lines[4].Kind);
Assert.Equal(GitDiffLineKind.Context, diff.Lines[5].Kind);
Assert.Equal(GitDiffLineKind.Removed, diff.Lines[6].Kind);
Assert.Equal(GitDiffLineKind.Added, diff.Lines[7].Kind);
}
[Fact]
public void Marks_binary_and_empty()
{
Assert.True(GitDiffParser.Parse("Binary files a/x and b/x differ\n", "x").IsBinary);
Assert.True(GitDiffParser.Parse("", "x").IsEmpty);
}
}

View File

@@ -15,7 +15,7 @@ public class GitPorcelainParserTests
# branch.ab +2 -1
1 .M N... 100644 100644 100644 a a src/App.cs
1 M. N... 100644 100644 100644 b b README.md
2 R. N... 100644 100644 100644 c c R100 old.txt new.txt
2 R. N... 100644 100644 100644 c c R100 new.txt old.txt
? bin/out.dll
? notes.md
! ignore.me
@@ -28,8 +28,79 @@ public class GitPorcelainParserTests
Assert.Equal(1, status.Behind);
Assert.False(status.WorkingTreeClean);
Assert.Equal("main · 3 modified · 2 untracked · 2 ahead · 1 behind", status.Badge);
Assert.Collection(
status.Changes,
c =>
{
Assert.Equal(GitChangeState.Staged, c.State);
Assert.Equal("old.txt", c.OriginalPath);
Assert.Equal("new.txt", c.Path);
Assert.Equal("old.txt → new.txt", c.DisplayPath);
Assert.Equal("renamed", c.ChangeLabel);
},
c =>
{
Assert.Equal(GitChangeState.Staged, c.State);
Assert.Equal("README.md", c.Path);
Assert.Equal("modified", c.ChangeLabel);
},
c =>
{
Assert.Equal(GitChangeState.Unstaged, c.State);
Assert.Equal("src/App.cs", c.Path);
Assert.Equal("modified", c.ChangeLabel);
},
c =>
{
Assert.Equal(GitChangeState.Untracked, c.State);
Assert.Equal("bin/out.dll", c.Path);
},
c =>
{
Assert.Equal(GitChangeState.Untracked, c.State);
Assert.Equal("notes.md", c.Path);
});
}
[Fact]
public void Lists_staged_and_unstaged_for_the_same_path()
{
var status = GitPorcelainParser.Parse("""
# branch.head main
1 MM N... 100644 100644 100644 a a src/Both.cs
""", @"C:\src");
Assert.NotNull(status);
Assert.Equal(1, status.ModifiedCount);
Assert.Equal(2, status.Changes.Count);
Assert.Equal(GitChangeState.Staged, status.Changes[0].State);
Assert.Equal(GitChangeState.Unstaged, status.Changes[1].State);
Assert.All(status.Changes, c => Assert.Equal("src/Both.cs", c.Path));
}
[Fact]
public void Lists_unmerged_and_quoted_paths()
{
var status = GitPorcelainParser.Parse("""
# branch.head topic
u UU N... 100644 100644 100644 100644 a b c conflict.txt
1 .M N... 100644 100644 100644 a a "my file.txt"
? docs/a file.md
""", @"C:\src");
Assert.NotNull(status);
Assert.Equal(2, status.ModifiedCount);
Assert.Equal(1, status.UntrackedCount);
Assert.Equal(GitChangeState.Unmerged, status.Changes[0].State);
Assert.Equal("conflict.txt", status.Changes[0].Path);
Assert.Equal("unmerged", status.Changes[0].ChangeLabel);
Assert.Equal("my file.txt", status.Changes[1].Path);
Assert.Equal("docs/a file.md", status.Changes[2].Path);
}
[Fact]
public void Unquote_decodes_c_escapes()
=> Assert.Equal("a\"b", GitPorcelainParser.Unquote("\"a\\\"b\""));
[Fact]
public void Clean_tree_uses_detached_oid_prefix()
{
@@ -95,4 +166,57 @@ public class GitRepoDetectorTests
[Fact]
public void Virtual_roots_are_not_repos()
=> Assert.Null(GitRepoDetector.FindRoot(LocationRoots.ThisPc, _ => true, _ => true));
[Fact]
public void Finds_gitdir_from_a_directory_and_a_gitfile()
{
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@"C:\src",
@"C:\src\.git",
@"C:\work",
@"D:\repo\.git\worktrees\topic"
};
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\work\.git" };
Assert.Equal(@"C:\src\.git", GitRepoDetector.FindGitDir(@"C:\src", dirs.Contains, files.Contains));
Assert.Equal(
@"D:\repo\.git\worktrees\topic",
GitRepoDetector.FindGitDir(
@"C:\work",
dirs.Contains,
files.Contains,
_ => "gitdir: D:/repo/.git/worktrees/topic"));
}
[Fact]
public void Detects_merge_and_rebase_in_progress()
{
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@"C:\src",
@"C:\src\.git",
@"C:\src\.git\rebase-merge",
@"C:\work",
@"D:\repo\.git\worktrees\topic"
};
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@"C:\src\.git\MERGE_HEAD",
@"C:\work\.git",
@"D:\repo\.git\worktrees\topic\CHERRY_PICK_HEAD"
};
Assert.Equal(GitOperationKind.Merge, GitRepoDetector.GetOperation(@"C:\src", dirs.Contains, files.Contains));
Assert.True(GitRepoDetector.IsOperationInProgress(@"C:\src", dirs.Contains, files.Contains));
dirs.Remove(@"C:\src\.git\rebase-merge");
files.Remove(@"C:\src\.git\MERGE_HEAD");
Assert.Equal(GitOperationKind.None, GitRepoDetector.GetOperation(@"C:\src", dirs.Contains, files.Contains));
Assert.False(GitRepoDetector.IsOperationInProgress(@"C:\src", dirs.Contains, files.Contains));
Assert.Equal(
GitOperationKind.CherryPick,
GitRepoDetector.GetOperation(
@"C:\work",
dirs.Contains,
files.Contains,
_ => "gitdir: D:/repo/.git/worktrees/topic"));
}
}

View File

@@ -45,7 +45,7 @@ public class SourceManagerTests
CapacityBytes = 64
}
];
await mgr.RefreshOnlineStateAsync();
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
var again = (await store.Sources.GetAllAsync()).Single();
Assert.Equal(key, again.StableKey);
Assert.Equal(@"G:\", again.LastRootPath);
@@ -129,7 +129,7 @@ public class SourceManagerTests
Assert.False(await mgr.ForgetDisconnectedAsync(@"Z:\"));
volumes.Online = [];
await mgr.RefreshOnlineStateAsync();
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
source = (await store.Sources.GetAllAsync()).Single();
Assert.True(mgr.CanForget(source));
Assert.True(await mgr.ForgetDisconnectedAsync(@"Z:\"));
@@ -164,7 +164,7 @@ public class SourceManagerTests
await store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Scanning, null);
await store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, 1);
await mgr.RefreshOnlineStateAsync();
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
source = (await store.Sources.GetAllAsync()).Single();
Assert.Equal(SourceStatus.Online, source.Status);
Assert.True(source.IsIndexed);
@@ -215,7 +215,7 @@ public class SourceManagerTests
Assert.Equal(guid, (await store.Sources.GetAllAsync()).Single().VolumeGuid);
volumes.Online = [];
await mgr.RefreshOnlineStateAsync();
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
Assert.True(await mgr.ForgetDisconnectedAsync(@"E:\"));
Assert.Empty(await store.Sources.GetAllAsync());
@@ -231,7 +231,7 @@ public class SourceManagerTests
CapacityBytes = 64
}
];
await mgr.RefreshOnlineStateAsync();
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
var restored = Assert.Single(await store.Sources.GetAllAsync());
Assert.Equal(guid, restored.VolumeGuid);
Assert.Equal(@"F:\", restored.LastRootPath);
@@ -269,12 +269,40 @@ public class SourceManagerTests
Assert.Empty(await mgr.ListUntrackedOnlineVolumesAsync());
Assert.Equal(imported.Id, (await store.Sources.GetAllAsync()).Single().Id);
}
[Fact]
public async Task Cached_refresh_skips_a_second_pass_until_forced()
{
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
var volumes = new FakeVolumes
{
Online =
[
new VolumeFingerprint { Kind = SourceKind.NtfsLocal, RootPath = @"C:\", DisplayName = "C:" }
]
};
var env = new FakeEnv(Path.GetDirectoryName(db)!);
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
await mgr.InitializeAsync();
var afterInit = volumes.EnumerateCalls;
await mgr.RefreshOnlineStateAsync();
Assert.Equal(afterInit, volumes.EnumerateCalls);
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
Assert.Equal(afterInit + 1, volumes.EnumerateCalls);
}
}
file sealed class FakeVolumes : IVolumeService
{
public List<VolumeFingerprint> Online { get; set; } = [];
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => Online;
public int EnumerateCalls;
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes()
{
Interlocked.Increment(ref EnumerateCalls);
return Online;
}
public VolumeFingerprint? Probe(string path)
{
var root = Path.GetPathRoot(path)?.TrimEnd('\\');