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:
@@ -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>
|
||||
|
||||
171
tests/Explorer.Application.Tests/GitCommandIntegrationTests.cs
Normal file
171
tests/Explorer.Application.Tests/GitCommandIntegrationTests.cs
Normal 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; }
|
||||
}
|
||||
114
tests/Explorer.Application.Tests/GitCommitPlannerTests.cs
Normal file
114
tests/Explorer.Application.Tests/GitCommitPlannerTests.cs
Normal 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
|
||||
};
|
||||
}
|
||||
86
tests/Explorer.Application.Tests/GitDiffParserTests.cs
Normal file
86
tests/Explorer.Application.Tests/GitDiffParserTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('\\');
|
||||
|
||||
@@ -438,3 +438,88 @@ public class FileClassifierTests
|
||||
private static FileCategory Classify(string name, bool isDirectory = false, bool isRepoRoot = false)
|
||||
=> FileClassifier.Classify(name, @"C:\Downloads\" + name, isDirectory, isDirectory ? AttributeFlags.Directory : 0, isRepoRoot).Category;
|
||||
}
|
||||
|
||||
public class GitChangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rename_and_delete_labels()
|
||||
{
|
||||
var renamed = new GitChange
|
||||
{
|
||||
Path = "new.txt",
|
||||
OriginalPath = "old.txt",
|
||||
Index = 'R',
|
||||
WorkTree = '.',
|
||||
State = GitChangeState.Staged
|
||||
};
|
||||
Assert.Equal("old.txt → new.txt", renamed.DisplayPath);
|
||||
Assert.Equal("renamed", renamed.ChangeLabel);
|
||||
Assert.Equal(@"C:\src\new.txt", renamed.ToFullPath(@"C:\src"));
|
||||
Assert.False(renamed.IsDeleted);
|
||||
|
||||
var deleted = new GitChange
|
||||
{
|
||||
Path = "gone.txt",
|
||||
Index = '.',
|
||||
WorkTree = 'D',
|
||||
State = GitChangeState.Unstaged
|
||||
};
|
||||
Assert.True(deleted.IsDeleted);
|
||||
Assert.Equal("deleted", deleted.ChangeLabel);
|
||||
}
|
||||
}
|
||||
|
||||
public class BoundedWaitTests
|
||||
{
|
||||
[Fact]
|
||||
public void Try_returns_the_result_when_work_finishes()
|
||||
=> Assert.True(BoundedWait.Try(() => true, TimeSpan.FromSeconds(1)));
|
||||
|
||||
[Fact]
|
||||
public void Try_returns_false_when_work_exceeds_the_timeout()
|
||||
=> Assert.False(BoundedWait.Try(
|
||||
() =>
|
||||
{
|
||||
Thread.Sleep(250);
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromMilliseconds(30)));
|
||||
}
|
||||
|
||||
public class GitCommandResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Display_prefers_summary_then_stderr_on_failure()
|
||||
{
|
||||
Assert.Equal("Committed.", GitCommandResult.Ok("Committed.").DisplayMessage);
|
||||
Assert.Equal(
|
||||
"not a fast-forward",
|
||||
new GitCommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
ExitCode = 1,
|
||||
StandardError = "not a fast-forward\n"
|
||||
}.DisplayMessage);
|
||||
Assert.Equal("git failed (exit 128).", new GitCommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
ExitCode = 128
|
||||
}.DisplayMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public class GitStatusBadgeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Includes_merging_instead_of_clean()
|
||||
{
|
||||
var status = new GitStatus
|
||||
{
|
||||
RepoRoot = @"C:\src",
|
||||
Branch = "main",
|
||||
Operation = GitOperationKind.Merge
|
||||
};
|
||||
Assert.Equal("main · merging", status.Badge);
|
||||
Assert.Equal("merging", status.OperationLabel);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user