diff --git a/AGENTS.md b/AGENTS.md index 6c66d5f6..dbe57dc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,7 @@ What follows is how TuiCode implements it today. ## Editor gutter (#23) - `EditorGutter` is a sibling view left of the `EditorTextView` inside `EditorTab`, not an adornment. It draws rows from `_text.Viewport.Y` (we never enable WordWrap, so viewport rows are model rows) and redraws on the text view's `ViewportChanged` / `UnwrappedCursorPositionChanged`. Anything that moves the text view (e.g. `SetHeader`) must move the gutter's `Y` too. -- Change markers diff the buffer against a **baseline** via the pure, TG-free `LineDiff` (Myers, after trimming common prefix/suffix). For a file git tracks it's the file at `HEAD` (#370), so a save doesn't clear what isn't committed; otherwise it's the lines at load, reset on `Save` and reload. `CommittedBaselines` (`Workbench/Git/`) reads `HEAD` in the background on open, save and reload (`EditorTab.BaselineReset`) with `git cat-file --filters`, so CRLF and `.gitattributes` checkouts compare clean, and hands it to `EditorTab.CommittedLines`; a read overtaken by a newer one, or by the tab closing, is dropped. Outside a repo (`GitRepository.Contains`, no git call), untracked, new or renamed since `HEAD`, or git failing, it stays null and the save baseline stands, quietly. Undoing back to the baseline clears the markers, which `IsDirty` doesn't. The diff is lazy: `EditorTab.OnEdited` invalidates it and the next draw recomputes, so a hidden gutter costs nothing. Past `LineDiff.MaxEdits` Myers gives up, and the gutter splits the region at lines that occur once on each side (as patience diff does) and diffs between them, so a branch's worth of changes still reads line by line; only a region with no such line is marked modified whole. `LineDiff.Hunks`, which the undo recorder and `AlignedDiff` use, still gives up outright. +- Change markers diff the buffer against a **baseline** via the pure, TG-free `LineDiff` (Myers, after trimming common prefix/suffix). For a file git tracks it's the file at `HEAD` (#370), so a save doesn't clear what isn't committed; otherwise it's the lines at load, reset on `Save` and reload. `CommittedBaselines` (`Workbench/Git/`) reads `HEAD` in the background on open, save and reload (`EditorTab.BaselineReset`) with `git cat-file --filters`, so CRLF and `.gitattributes` checkouts compare clean, and hands it to `EditorTab.CommittedLines`; a read overtaken by a newer one, or by the tab closing, is dropped. Outside a repo (`GitRepository.Contains`, no git call), untracked, new or renamed since `HEAD`, or git failing, it stays null and the save baseline stands, quietly. `HEAD` moving outside the editor — a commit, checkout, reset or pull in another pane — re-reads every open tab from that repo (#371): `HeadWatcher` watches each repo's git dir for `HEAD`, and its common dir for `packed-refs` and, recursively, `refs/heads`, which `GitRepository.Dirs` finds from the filesystem (a linked worktree's `.git` file names its own git dir, whose `commondir` names the shared one). Only `HEAD`, `packed-refs` and the branch `HEAD` names count, so `git status` rewriting the index reads nothing. Events are debounced 250 ms like `DiskWatcher`'s, and a repo whose watch couldn't be set up re-reads a tab when it's shown instead. Undoing back to the baseline clears the markers, which `IsDirty` doesn't. The diff is lazy: `EditorTab.OnEdited` invalidates it and the next draw recomputes, so a hidden gutter costs nothing. Past `LineDiff.MaxEdits` Myers gives up, and the gutter splits the region at lines that occur once on each side (as patience diff does) and diffs between them, so a branch's worth of changes still reads line by line; only a region with no such line is marked modified whole. `LineDiff.Hunks`, which the undo recorder and `AlignedDiff` use, still gives up outright. - `AlignedDiff` (the model for the side-by-side diff tab, #61) lays `LineDiff.Hunks` out as rows: inside a hunk, lines pair as modified until the shorter side runs out, then the rest are left- or right-only. It takes its own give-up limit, since a revision can be far more than `MaxEdits` away from the buffer. - The diff's input comes from `LineSnapshot` (`EditorTextView.Snapshot`, shared with syntax colouring), not `EditorTextView.LineStrings` (which builds a string for every line). TG edits a line's `List` in place, so the snapshot spots changed lines by reference plus `List`'s private `_version` (read via `[UnsafeAccessor]`, AOT-safe), trims the unchanged prefix/suffix, and re-reads only the middle. Unchanged lines keep their string instances, so `LineDiff`'s comparisons against the baseline hit `string.Equals`' reference fast path. Don't use `ContentsChanged`'s row as a change range instead: TG reports the start of some multi-line edits and the end of others, and raises nothing for the kill commands. - Edits must reach `EditorTab.OnEdited`, which is why the `Content` setter calls it directly. `TextView.Text =` raises `ContentsChanged` as well in TG 2.1.0, so setting the whole buffer arrives there anyway — which is why a reload (#269) holds `EditorTab._loading` over it: taking up the file on disk isn't an edit and mustn't dirty the tab. diff --git a/src/TuiCode.Workbench/Git/CommittedBaselines.cs b/src/TuiCode.Workbench/Git/CommittedBaselines.cs index 2ae4dfb3..7cee8599 100644 --- a/src/TuiCode.Workbench/Git/CommittedBaselines.cs +++ b/src/TuiCode.Workbench/Git/CommittedBaselines.cs @@ -5,23 +5,28 @@ namespace TuiCode.Workbench.Git; /// /// Gives each tab its file at HEAD to mark changes against, so a save doesn't clear what isn't committed -/// (#370). Read in the background on open, save and reload; wherever git has no answer the tab keeps its last save. +/// (#370). Read in the background on open, save and reload, and again for every tab in a repo whose HEAD +/// moves (#371); wherever git has no answer the tab keeps its last save. /// -internal sealed class CommittedBaselines +internal sealed class CommittedBaselines : IDisposable { private readonly EditorGroup _group; private readonly IGitCli _git; private readonly Action _post; - private readonly Dictionary _reads = []; + private readonly HeadWatcher _head; + private readonly Dictionary _reads = []; private readonly List _pending = []; - public CommittedBaselines(EditorGroup group, IGitCli git, Action post) + public CommittedBaselines(EditorGroup group, IGitCli git, Action post, HeadWatcher head) { _group = group; _git = git; _post = post; + _head = head; _group.TabsChanged += (_, _) => Follow(); _group.BaselineReset += (_, tab) => Read(tab); + _group.ActiveTabChanged += OnActiveTabChanged; + _head.Moved += OnHeadMoved; Follow(); } @@ -42,13 +47,28 @@ private void Follow() foreach (var tab in open) if (!_reads.TryGetValue(tab, out var read) || read.Path != tab.File.FullName) Read(tab); + _head.Follow(_reads.Values.Select(r => r.Repo).OfType().Distinct()); + } + + private void OnHeadMoved(object? sender, IReadOnlyList repos) + { + foreach (var (tab, read) in _reads.ToList()) + if (read.Repo is { } repo && repos.Contains(repo)) + Read(tab); + } + + private void OnActiveTabChanged(object? sender, EditorTab? tab) + { + if (tab is not null && _reads.TryGetValue(tab, out var read) && read.Repo is { } repo && !_head.IsWatching(repo)) + Read(tab); } private void Read(EditorTab tab) { var path = tab.File.FullName; var read = _reads.TryGetValue(tab, out var last) ? last.Read + 1 : 0; - _reads[tab] = (path, read); + var repo = last.Path == path ? last.Repo : GitRepository.Dirs(tab.File.Directory); + _reads[tab] = (path, repo, read); if (!GitRepository.Contains(tab.File.Directory)) { if (tab.CommittedLines is not null) tab.CommittedLines = null; @@ -60,7 +80,7 @@ await _git.ShowCheckedOutFileAsync(path) is { Succeeded: true, Value: { } text } .ContinueWith(t => _post(() => { // A read that returns after a newer one started, or after the tab closed, is stale. - if (_reads.TryGetValue(tab, out var current) && current == (path, read)) + if (_reads.TryGetValue(tab, out var current) && current.Path == path && current.Read == read) tab.CommittedLines = t.IsCompletedSuccessfully ? t.Result : null; }), TaskScheduler.Default); lock (_pending) @@ -69,4 +89,11 @@ await _git.ShowCheckedOutFileAsync(path) is { Succeeded: true, Value: { } text } _pending.Add(reading); } } + + public void Dispose() + { + _group.ActiveTabChanged -= OnActiveTabChanged; + _head.Moved -= OnHeadMoved; + _head.Dispose(); + } } diff --git a/src/TuiCode.Workbench/Git/GitRepository.cs b/src/TuiCode.Workbench/Git/GitRepository.cs index c2f06fa7..4820f3e7 100644 --- a/src/TuiCode.Workbench/Git/GitRepository.cs +++ b/src/TuiCode.Workbench/Git/GitRepository.cs @@ -7,13 +7,45 @@ internal static class GitRepository /// Walks up for .git — a directory, or the file a worktree or submodule has instead. Command /// enablement is read while the palette builds its rows, which can't answer in time. /// - public static bool Contains(IDirectoryInfo? folder) + public static bool Contains(IDirectoryInfo? folder) => DotGit(folder) is not null; + + /// Where HEAD and the branches live; null outside a repo or for a .git file we can't read. + public static GitDirs? Dirs(IDirectoryInfo? folder) + { + if (DotGit(folder) is not { } dotGit) return null; + var fs = folder!.FileSystem; + if (fs.Directory.Exists(dotGit)) return new GitDirs(dotGit, dotGit); + + try + { + var line = fs.File.ReadLines(dotGit).FirstOrDefault(); + if (line is null || !line.StartsWith("gitdir:", StringComparison.Ordinal)) return null; + var gitDir = Resolve(fs, fs.Path.GetDirectoryName(dotGit)!, line["gitdir:".Length..].Trim()); + var commonDirFile = fs.Path.Combine(gitDir, "commondir"); + var commonDir = fs.File.Exists(commonDirFile) + ? Resolve(fs, gitDir, fs.File.ReadAllText(commonDirFile).Trim()) + : gitDir; + return new GitDirs(gitDir, commonDir); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException) + { + return null; + } + } + + private static string? DotGit(IDirectoryInfo? folder) { for (var dir = folder; dir is not null; dir = dir.Parent) { var dotGit = dir.FileSystem.Path.Combine(dir.FullName, ".git"); - if (dir.FileSystem.Directory.Exists(dotGit) || dir.FileSystem.File.Exists(dotGit)) return true; + if (dir.FileSystem.Directory.Exists(dotGit) || dir.FileSystem.File.Exists(dotGit)) return dotGit; } - return false; + return null; } + + private static string Resolve(IFileSystem fs, string from, string path) => + fs.Path.TrimEndingDirectorySeparator(fs.Path.GetFullPath(fs.Path.Combine(from, path))); } + +/// A linked worktree's own HEAD is in ; the branches it shares are in . +internal sealed record GitDirs(string GitDir, string CommonDir); diff --git a/src/TuiCode.Workbench/Git/HeadWatcher.cs b/src/TuiCode.Workbench/Git/HeadWatcher.cs new file mode 100644 index 00000000..23d4e767 --- /dev/null +++ b/src/TuiCode.Workbench/Git/HeadWatcher.cs @@ -0,0 +1,170 @@ +using Microsoft.Extensions.Logging; + +namespace TuiCode.Workbench.Git; + +/// Notices HEAD moving in the open tabs' repos, whoever moved it (#371). +internal sealed class HeadWatcher : IDisposable +{ + // A rebase moves HEAD once per commit it replays. + private static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(250); + + private const NotifyFilters Events = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size; + + private readonly IFileSystem _fileSystem; + private readonly Action _schedule; + private readonly ILogger _logger; + private readonly Dictionary _watchers = new(StringComparer.Ordinal); + private readonly HashSet _followed = []; + private readonly HashSet _pending = new(StringComparer.Ordinal); + private readonly Lock _gate = new(); + private bool _armed; + private bool _disposed; + + /// The repos whose HEAD may have moved, once the events have settled. Raised on the UI thread. + public event EventHandler>? Moved; + + /// Runs the flush on the UI thread once the debounce window has passed. + public HeadWatcher(IFileSystem fileSystem, Action schedule, ILogger logger) + { + _fileSystem = fileSystem; + _schedule = schedule; + _logger = logger; + } + + internal int WatcherCount + { + get { lock (_gate) return _watchers.Count; } + } + + public bool IsWatching(GitDirs repo) + { + lock (_gate) return Directories(repo).All(d => _watchers.ContainsKey(d.Path)); + } + + /// Watch exactly , adding and dropping watchers to match. + public void Follow(IEnumerable repos) + { + if (_disposed) return; + List<(string Path, bool Recursive)> missing; + lock (_gate) + { + _followed.Clear(); + _followed.UnionWith(repos); + var wanted = _followed.SelectMany(Directories).Distinct().ToList(); + foreach (var gone in _watchers.Keys.Where(d => wanted.All(w => w.Path != d)).ToList()) Drop(gone); + missing = wanted.Where(w => !_watchers.ContainsKey(w.Path)).ToList(); + } + foreach (var (path, recursive) in missing) Add(path, recursive); + } + + private IEnumerable<(string Path, bool Recursive)> Directories(GitDirs repo) + { + yield return (repo.GitDir, false); + if (repo.CommonDir != repo.GitDir) yield return (repo.CommonDir, false); + yield return (BranchesOf(repo), true); + } + + private string BranchesOf(GitDirs repo) => _fileSystem.Path.Combine(repo.CommonDir, "refs", "heads"); + + private void Add(string directory, bool recursive) + { + IFileSystemWatcher? watcher = null; + try + { + watcher = _fileSystem.FileSystemWatcher.New(directory); + watcher.IncludeSubdirectories = recursive; + watcher.NotifyFilter = Events; + watcher.Changed += (_, e) => OnEvent(e, recursive); + watcher.Created += (_, e) => OnEvent(e, recursive); + watcher.Deleted += (_, e) => OnEvent(e, recursive); + watcher.Renamed += (_, e) => OnEvent(e, recursive); + watcher.Error += (_, e) => OnError(directory, e.GetException()); + watcher.EnableRaisingEvents = true; + lock (_gate) _watchers[directory] = watcher; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or NotImplementedException) + { + watcher?.Dispose(); + _logger.LogWarning(e, "Not watching {Directory} for commits and checkouts", directory); + } + } + + private void OnError(string directory, Exception exception) + { + _logger.LogWarning(exception, "Stopped watching {Directory} for commits and checkouts", directory); + lock (_gate) Drop(directory); + } + + // Under _gate. + private void Drop(string directory) + { + if (_watchers.Remove(directory, out var watcher)) watcher.Dispose(); + } + + // Git writes a ref to .lock and renames it into place, so the rename is what lands on the ref. + private void OnEvent(FileSystemEventArgs e, bool branches) + { + if (_disposed) return; + var name = _fileSystem.Path.GetFileName(e.FullPath); + if (branches ? name.EndsWith(".lock", StringComparison.Ordinal) : name is not ("HEAD" or "packed-refs")) return; + var path = _fileSystem.Path.GetFullPath(e.FullPath); + lock (_gate) + { + _pending.Add(path); + if (_armed) return; + _armed = true; + } + _schedule(Debounce, Flush); + } + + private void Flush() + { + string[] changed; + GitDirs[] followed; + lock (_gate) + { + _armed = false; + changed = _pending.ToArray(); + _pending.Clear(); + followed = _followed.ToArray(); + } + if (_disposed) return; + var moved = followed.Where(repo => HeadPaths(repo).Any(p => changed.Contains(p, StringComparer.Ordinal))).ToList(); + if (moved.Count > 0) Moved?.Invoke(this, moved); + } + + private IEnumerable HeadPaths(GitDirs repo) + { + var head = _fileSystem.Path.Combine(repo.GitDir, "HEAD"); + yield return head; + yield return _fileSystem.Path.Combine(repo.CommonDir, "packed-refs"); + if (Branch(head) is { } branch) + yield return _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(BranchesOf(repo), branch)); + } + + // Null when HEAD is detached. + private string? Branch(string head) + { + const string prefix = "ref: refs/heads/"; + try + { + var line = _fileSystem.File.ReadLines(head).FirstOrDefault(); + return line is not null && line.StartsWith(prefix, StringComparison.Ordinal) ? line[prefix.Length..].Trim() : null; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return null; + } + } + + public void Dispose() + { + _disposed = true; + lock (_gate) + { + foreach (var directory in _watchers.Keys.ToList()) Drop(directory); + _followed.Clear(); + _pending.Clear(); + } + } +} diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 9d139341..85b68ead 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -185,7 +185,8 @@ public WorkbenchHost( _workbench.Sidebar.Explorer, _workbench.StatusBar.SetMessage, new DiskWatcher(fileSystem, ScheduleFlush, _logger)); - _baselines = new CommittedBaselines(_workbench.Editor.Group, _git, action => _app.Invoke(action)); + _baselines = new CommittedBaselines( + _workbench.Editor.Group, _git, action => _app.Invoke(action), new HeadWatcher(fileSystem, ScheduleFlush, _logger)); var explorer = _workbench.Sidebar.Explorer; _folderWatcher = new FolderWatcher(fileSystem, ScheduleFlush, _logger); @@ -2718,6 +2719,7 @@ public void Dispose() _workbench.Editor.Group.CursorMoved -= OnEditorCursorMoved; _workbench.Editor.Group.ActiveTabChanged -= OnActiveTabChanged; _diskChanges.Dispose(); + _baselines.Dispose(); _folderWatcher.Dispose(); _find.Dispose(); _terminalCursors.Dispose(); diff --git a/tests/TuiCode.Tests/CommittedBaselinesTests.cs b/tests/TuiCode.Tests/CommittedBaselinesTests.cs index 97a63d6e..b7d46dd6 100644 --- a/tests/TuiCode.Tests/CommittedBaselinesTests.cs +++ b/tests/TuiCode.Tests/CommittedBaselinesTests.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Logging.Abstractions; using TuiCode.Abstractions; using TuiCode.Editor; using TuiCode.Workbench.Git; @@ -8,20 +9,21 @@ namespace TuiCode.Tests; public class CommittedBaselinesTests { - private readonly MockFileSystem _fs = new(); + private readonly WatchableFileSystem _fs = new(); private readonly FakeGitCli _git = new(); private readonly EditorGroup _group = new(); private readonly CommittedBaselines _baselines; private readonly SemaphoreSlim _applied = new(0); + private readonly List _flushes = []; public CommittedBaselinesTests() { - _fs.AddDirectory(Full("/repo/.git")); + Repo("/repo"); _baselines = new CommittedBaselines(_group, _git, action => { action(); _applied.Release(); - }); + }, new HeadWatcher(_fs, (_, flush) => _flushes.Add(flush), NullLogger.Instance)); } [Fact] @@ -148,6 +150,177 @@ public async Task A_CRLF_checkout_matches_its_HEAD_line_for_line() Assert.All(tab.LineChanges, c => Assert.Equal(None, c)); } + [Fact] + public async Task A_commit_elsewhere_clears_the_markers_on_every_tab_from_that_repo() + { + _git.HeadFiles[Full("/repo/a.cs")] = "one\n"; + _git.HeadFiles[Full("/repo/b.cs")] = "uno\n"; + var a = await Open("/repo/a.cs", "ONE\n"); + var b = await Open("/repo/b.cs", "UNO\n"); + + _git.HeadFiles[Full("/repo/a.cs")] = "ONE\n"; + _git.HeadFiles[Full("/repo/b.cs")] = "UNO\n"; + await Commit("/repo/.git", "refs/heads/main"); + + Assert.All(a.LineChanges, c => Assert.Equal(None, c)); + Assert.All(b.LineChanges, c => Assert.Equal(None, c)); + } + + [Fact] + public async Task A_partial_commit_leaves_only_the_uncommitted_lines_marked() + { + _git.HeadFiles[Full("/repo/a.cs")] = "one\ntwo\n"; + var tab = await Open("/repo/a.cs", "ONE\nTWO\n"); + + _git.HeadFiles[Full("/repo/a.cs")] = "ONE\ntwo\n"; + await Commit("/repo/.git", "refs/heads/main"); + + Assert.Equal([None, Modified, None], tab.LineChanges); + } + + [Fact] + public async Task Undoing_a_commit_brings_its_markers_back() + { + _git.HeadFiles[Full("/repo/a.cs")] = "ONE\n"; + var tab = await Open("/repo/a.cs", "ONE\n"); + + _git.HeadFiles[Full("/repo/a.cs")] = "one\n"; + await Commit("/repo/.git", "refs/heads/main"); + + Assert.Equal([Modified, None], tab.LineChanges); + } + + [Theory] + [InlineData("HEAD")] + [InlineData("packed-refs")] + public async Task Switching_branch_or_packing_refs_reads_HEAD_again(string file) + { + await Open("/repo/a.cs", "one\n"); + + await Commit("/repo/.git", file, watched: "/repo/.git"); + + Assert.Equal(2, _git.HeadReads.Count); + } + + [Fact] + public async Task A_branch_with_a_slash_in_its_name_is_followed_into_its_folder() + { + _fs.File.WriteAllText(Full("/repo/.git/HEAD"), "ref: refs/heads/feat/x\n"); + await Open("/repo/a.cs", "one\n"); + + await Commit("/repo/.git", "refs/heads/feat/x"); + + Assert.Equal(2, _git.HeadReads.Count); + } + + [Theory] + [InlineData("refs/heads/other")] + [InlineData("index")] + [InlineData("refs/heads/main.lock")] + public async Task Nothing_but_HEAD_and_its_branch_reads_HEAD_again(string file) + { + await Open("/repo/a.cs", "one\n"); + + await Commit("/repo/.git", file, watched: file == "index" ? "/repo/.git" : "/repo/.git/refs/heads"); + + Assert.Single(_git.HeadReads); + } + + [Fact] + public async Task Tabs_from_another_repo_and_outside_any_repo_are_left_alone() + { + Repo("/other"); + await Open("/repo/a.cs", "one\n"); + await Open("/other/b.cs", "one\n"); + await Open("/elsewhere/c.cs", "one\n"); + + await Commit("/repo/.git", "refs/heads/main"); + + Assert.Equal([Full("/repo/a.cs"), Full("/other/b.cs"), Full("/repo/a.cs")], _git.HeadReads); + } + + [Fact] + public async Task A_rebase_reads_HEAD_once_when_it_settles() + { + await Open("/repo/a.cs", "one\n"); + var branches = _fs.Watchers.For(Full("/repo/.git/refs/heads")); + + for (var i = 0; i < 5; i++) branches.Raise(WatcherChangeTypes.Renamed, Full("/repo/.git/refs/heads/main")); + _fs.Watchers.For(Full("/repo/.git")).Raise(WatcherChangeTypes.Renamed, Full("/repo/.git/HEAD")); + await Flush(); + + Assert.Equal(2, _git.HeadReads.Count); + } + + [Fact] + public async Task A_linked_worktree_follows_its_own_HEAD_and_the_shared_branches() + { + _fs.AddFile(Full("/repo/.git/worktrees/wt/HEAD"), new MockFileData("ref: refs/heads/wt\n")); + _fs.AddFile(Full("/repo/.git/worktrees/wt/commondir"), new MockFileData("../..\n")); + _fs.AddFile(Full("/wt/.git"), new MockFileData($"gitdir: {Full("/repo/.git/worktrees/wt")}\n")); + await Open("/wt/a.cs", "one\n"); + + await Commit("/repo/.git", "refs/heads/wt"); + await Commit("/repo/.git/worktrees/wt", "HEAD", watched: "/repo/.git/worktrees/wt"); + await Commit("/repo/.git", "refs/heads/main"); + + Assert.Equal(3, _git.HeadReads.Count); + } + + [Fact] + public async Task A_detached_HEAD_moves_with_HEAD_alone() + { + _fs.File.WriteAllText(Full("/repo/.git/HEAD"), "4a91c0e2f00d\n"); + await Open("/repo/a.cs", "one\n"); + + await Commit("/repo/.git", "refs/heads/main"); + await Commit("/repo/.git", "HEAD", watched: "/repo/.git"); + + Assert.Equal(2, _git.HeadReads.Count); + } + + [Fact] + public async Task A_repo_that_cant_be_watched_catches_up_when_its_tab_is_shown() + { + _fs.Watchers.FailFor.Add(Full("/repo/.git/refs/heads")); + var a = await Open("/repo/a.cs", "ONE\n"); + await Open("/elsewhere/b.cs", "one\n"); + var reads = _git.HeadReads.Count; + + _git.HeadFiles[Full("/repo/a.cs")] = "one\n"; + _group.Focus(a.File.FullName); + await _baselines.Idle; + + Assert.Equal(reads + 1, _git.HeadReads.Count); + Assert.Equal([Modified, None], a.LineChanges); + } + + [Fact] + public async Task A_watched_repo_reads_nothing_when_its_tab_is_shown() + { + var a = await Open("/repo/a.cs", "one\n"); + await Open("/repo/b.cs", "one\n"); + + _group.Focus(a.File.FullName); + await _baselines.Idle; + + Assert.Equal(2, _git.HeadReads.Count); + } + + [Fact] + public async Task The_watch_ends_when_the_last_tab_from_its_repo_closes() + { + var a = await Open("/repo/a.cs", "one\n"); + await Open("/repo/b.cs", "one\n"); + + _group.CloseUnder(a.File.FullName); + var whileOneOpen = _fs.Watchers.Live.Count(); + _group.CloseAll(); + + Assert.Equal(2, whileOneOpen); + Assert.Empty(_fs.Watchers.Live); + } + private async Task Open(string path, string content) { var tab = _group.OpenOrFocus(Write(path, content)); @@ -162,4 +335,25 @@ private IFileInfo Write(string path, string content) } private string Full(string path) => _fs.Path.GetFullPath(path); + + private void Repo(string root) + { + _fs.AddFile(Full($"{root}/.git/HEAD"), new MockFileData("ref: refs/heads/main\n")); + _fs.AddDirectory(Full($"{root}/.git/refs/heads")); + } + + /// Git writes under the way it does: to a lock, renamed into place. + private async Task Commit(string gitDir, string file, string? watched = null) + { + var path = Full($"{gitDir}/{file}"); + _fs.Watchers.For(Full(watched ?? $"{gitDir}/refs/heads")).Raise(WatcherChangeTypes.Renamed, path); + await Flush(); + } + + private async Task Flush() + { + foreach (var flush in _flushes.ToList()) flush(); + _flushes.Clear(); + await _baselines.Idle; + } } diff --git a/tests/TuiCode.Tests/FakeWatchers.cs b/tests/TuiCode.Tests/FakeWatchers.cs index e7cb2584..ac73aeb5 100644 --- a/tests/TuiCode.Tests/FakeWatchers.cs +++ b/tests/TuiCode.Tests/FakeWatchers.cs @@ -60,7 +60,7 @@ internal sealed class FakeFileSystemWatcher(IFileSystem fileSystem, string path) /// What an editor that writes a temp file and renames it over the original looks like. public void Raise(WatcherChangeTypes change, string fullPath) { - var name = FileSystem.Path.GetFileName(fullPath); + var name = FileSystem.Path.GetRelativePath(Path, fullPath); var args = new FileSystemEventArgs(change, Path, name); switch (change) { diff --git a/tests/TuiCode.Tests/GitRepositoryTests.cs b/tests/TuiCode.Tests/GitRepositoryTests.cs new file mode 100644 index 00000000..ec2401d3 --- /dev/null +++ b/tests/TuiCode.Tests/GitRepositoryTests.cs @@ -0,0 +1,68 @@ +using TuiCode.Workbench.Git; + +namespace TuiCode.Tests; + +public class GitRepositoryTests +{ + private readonly MockFileSystem _fs = new(); + + [Fact] + public void A_plain_checkout_keeps_HEAD_and_its_branches_in_one_git_dir() + { + _fs.AddDirectory(Full("/repo/.git")); + _fs.AddDirectory(Full("/repo/src")); + + Assert.Equal(new GitDirs(Full("/repo/.git"), Full("/repo/.git")), GitRepository.Dirs(Folder("/repo/src"))); + } + + [Fact] + public void A_submodule_s_git_file_is_read_relative_to_where_it_is() + { + _fs.AddFile(Full("/repo/lib/.git"), new MockFileData("gitdir: ../.git/modules/lib\n")); + _fs.AddDirectory(Full("/repo/.git/modules/lib")); + + var dirs = GitRepository.Dirs(Folder("/repo/lib")); + + Assert.Equal(new GitDirs(Full("/repo/.git/modules/lib"), Full("/repo/.git/modules/lib")), dirs); + } + + [Theory] + [InlineData("")] + [InlineData("not a git file\n")] + public void A_git_file_that_doesn_t_say_where_to_look_gives_no_dirs(string content) + { + _fs.AddFile(Full("/repo/.git"), new MockFileData(content)); + + Assert.Null(GitRepository.Dirs(Folder("/repo"))); + Assert.True(GitRepository.Contains(Folder("/repo"))); + } + + [Fact] + public void Outside_a_repo_there_are_no_dirs() + { + _fs.AddDirectory(Full("/elsewhere")); + + Assert.Null(GitRepository.Dirs(Folder("/elsewhere"))); + } + + [Fact] + public void A_linked_worktree_has_its_own_HEAD_and_shares_the_main_checkout_s_branches() + { + using var repo = new TempRepo(init: true); + repo.Commit("a.cs", "one\n", "First"); + repo.Git("worktree", "add", "-q", "-b", "feature", repo.File("feature")); + var fs = new FileSystem(); + + var main = GitRepository.Dirs(fs.DirectoryInfo.New(repo.Path))!; + var linked = GitRepository.Dirs(fs.DirectoryInfo.New(repo.File("feature")))!; + + Assert.Equal("ref: refs/heads/feature", File.ReadAllText(Path.Combine(linked.GitDir, "HEAD")).Trim()); + Assert.True(File.Exists(Path.Combine(linked.CommonDir, "refs", "heads", "feature"))); + Assert.Equal(File.ReadAllText(Path.Combine(main.CommonDir, "config")), File.ReadAllText(Path.Combine(linked.CommonDir, "config"))); + Assert.NotEqual(main.GitDir, linked.GitDir); + } + + private IDirectoryInfo Folder(string path) => _fs.DirectoryInfo.New(Full(path)); + + private string Full(string path) => _fs.Path.GetFullPath(path); +}