diff --git a/AGENTS.md b/AGENTS.md index 0b61e54..97367ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,7 +283,7 @@ What follows is how TuiCode implements it today. ## Review tab (#126) -- The sidebar's **Review** tab (`ReviewView`, `Review/`) lists what the current branch changes against the merge base of `HEAD` with the default branch (`IGitCli.GetDefaultBranchAsync`: `origin/HEAD`'s target, else `main`, else `master`). `git diff --name-status -M ` compares with the working copy, so staged and unstaged edits show; untracked files don't. Files group under one node per folder path, as in the pitch mockup, not a nested tree. +- The sidebar's **Review** tab (`ReviewView`, `Review/`) lists what the current branch changes against the merge base of `HEAD` with the default branch (`IGitCli.GetDefaultBranchAsync`: `origin/HEAD`'s target, else `main`, else `master`). `git diff --name-status -M ` compares with the working copy, so staged and unstaged edits show, and so do untracked files git doesn't ignore (#597): `GitCli` diffs with them added as intent-to-add to a throwaway copy of the index (`GIT_INDEX_FILE`), never the user's, so a restored deletion that matches the base drops off and a new file shows as added. Files group under one node per folder path, as in the pitch mockup, not a nested tree. - **Focus review** (`Ctrl+Shift+R`, `fr`) shows and focuses it. It refreshes when the tab is switched to, when the sidebar is shown on it, on the command while showing, on file save and on opening a folder; hosted, it loads on a background task like the Find tab. It sits in a `LoadStateView` the sidebar wraps it in (`ReviewView.LoadState`): the first load says `Loading changes…` in the pane, and a refresh keeps the files with a spinner on the tab's border. A refresh that fails while files are listed keeps them and says so on the status bar (`RefreshFailed`). - `Enter` on a file opens it and a diff tab against the merge base, titled with the base branch, e.g. `a.cs ↔ origin/main`. The left side is read with `ShowRepoFileAsync` (repo-relative, so a rename's old path works even when its folder is gone), and its `LeftKey` is `:`, so it never matches a `ctr` diff against the same branch name. - **Stepping through the review (#181).** Next/previous change (`Alt+Down`/`Alt+Up`) past a review diff's last/first change moves on to the next file the tab lists, at its first (or last) change, and closes the diff it leaves so a review doesn't pile up diff tabs; the files themselves stay open, skipped ones included. A file whose buffer already matches the base is stepped over. At either end nothing moves and the status bar says `Last change in the review` / `First change in the review`. What makes a diff part of the review is `DiffTab.Review`, a `ReviewSpot` (the merge base, plus the file's place in the list) the host sets as it opens one — `cts`/`cto`/`ctr` diffs leave it null and so still stop at their last change. It also gives the status bar `File 3 of 7`, shown before `Change 1 of 4`, and the Review tab's selection follows whichever file is showing. diff --git a/src/TuiCode.Abstractions/IGitCli.cs b/src/TuiCode.Abstractions/IGitCli.cs index c1c4599..fb22ea1 100644 --- a/src/TuiCode.Abstractions/IGitCli.cs +++ b/src/TuiCode.Abstractions/IGitCli.cs @@ -49,7 +49,7 @@ public interface IGitCli /// The full hash of the best common ancestor of two revisions, or null when they share no history. Task> GetMergeBaseAsync(string path, string first, string second, CancellationToken cancellationToken = default); - /// Files that differ between and the working copy, staged or not, with repo-relative paths. + /// Files that differ between and the working copy, staged, unstaged or untracked and not ignored, with repo-relative paths. Task>> GetChangedFilesAsync(string path, string revision, CancellationToken cancellationToken = default); /// The lines each file in adds and removes, keyed by its (new) repo-relative path. diff --git a/src/TuiCode.Workbench/Git/GitCli.cs b/src/TuiCode.Workbench/Git/GitCli.cs index 11e0bc4..b2c3ad9 100644 --- a/src/TuiCode.Workbench/Git/GitCli.cs +++ b/src/TuiCode.Workbench/Git/GitCli.cs @@ -209,7 +209,7 @@ public async Task>> GetChangedFilesAsync(stri if (!IsSafeRevision(revision)) return GitResult>.Failure($"Unknown revision '{revision}'"); - var run = await RunAsync(DirectoryOf(path), + var run = await DiffWithUntrackedAsync(DirectoryOf(path), ["diff", "--name-status", "-z", "-M", "--no-ext-diff", revision, "--"], cancellationToken); return run.Failure is null && run.ExitCode == 0 ? GitResult>.Success(ParseChanges(run.Output)) @@ -221,13 +221,36 @@ public async Task>> GetLineC if (!IsSafeRevision(revision)) return GitResult>.Failure($"Unknown revision '{revision}'"); - var run = await RunAsync(DirectoryOf(path), + var run = await DiffWithUntrackedAsync(DirectoryOf(path), ["diff", "--numstat", "-z", "-M", "--no-ext-diff", revision, "--"], cancellationToken); return run.Failure is null && run.ExitCode == 0 ? GitResult>.Success(ParseLineCounts(run.Output)) : GitResult>.Failure(ErrorMessage(run)); } + /// Diffs with untracked, not-ignored files added as intent-to-add to a throwaway copy of the index. + private async Task DiffWithUntrackedAsync(string directory, string[] diff, CancellationToken cancellationToken) + { + var located = await RunAsync(directory, ["rev-parse", "--git-path", "index"], cancellationToken); + if (located.Failure is not null || located.ExitCode != 0) + return located; + + var index = fileSystem.Path.GetFullPath(fileSystem.Path.Combine(directory, located.Output.TrimEnd('\r', '\n'))); + var scratch = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), $"tuicode-index-{Guid.NewGuid():N}"); + try + { + if (fileSystem.File.Exists(index)) + fileSystem.File.Copy(index, scratch); + await RunAsync(directory, ["add", "--intent-to-add", "--ignore-errors", "--", ":/"], cancellationToken, indexFile: scratch); + return await RunAsync(directory, diff, cancellationToken, indexFile: scratch); + } + finally + { + fileSystem.File.Delete(scratch); + fileSystem.File.Delete(scratch + ".lock"); + } + } + public async Task> AddWorktreeAsync(string repoRoot, string worktreePath, CancellationToken cancellationToken = default) { if (fileSystem.Directory.Exists(worktreePath)) @@ -486,7 +509,8 @@ private static string ErrorMessage(CliRun run) } private Task RunAsync( - string workingDirectory, IEnumerable arguments, CancellationToken cancellationToken, TimeSpan? timeout = null, string? input = null) + string workingDirectory, IEnumerable arguments, CancellationToken cancellationToken, TimeSpan? timeout = null, string? input = null, + string? indexFile = null) { var info = new ProcessStartInfo(executable) { @@ -506,6 +530,8 @@ private Task RunAsync( // English messages, so "not a git repository" can be recognised. info.Environment["LC_ALL"] = "C"; info.Environment["GIT_OPTIONAL_LOCKS"] = "0"; + if (indexFile is not null) + info.Environment["GIT_INDEX_FILE"] = indexFile; return CliProcess.RunAsync("git", info, timeout ?? _timeout, cancellationToken, input); } diff --git a/tests/TuiCode.Tests/GitCliTests.cs b/tests/TuiCode.Tests/GitCliTests.cs index a4f3042..e7f9b79 100644 --- a/tests/TuiCode.Tests/GitCliTests.cs +++ b/tests/TuiCode.Tests/GitCliTests.cs @@ -388,6 +388,7 @@ public async Task Lists_a_branch_changes_against_its_merge_base_with_the_default new GitChange(GitChangeKind.Modified, "src/mod.cs"), new GitChange(GitChangeKind.Added, "src/new.cs"), new GitChange(GitChangeKind.Added, "staged.cs"), + new GitChange(GitChangeKind.Added, "untracked.cs"), ], changes.OrderBy(c => c.Path, StringComparer.Ordinal)); Assert.Equal("a file long enough for git to spot the rename\n", (await git.ShowRepoFileAsync(repo.Path, "old/moved.cs", mergeBase, ct)).Value); Assert.Null((await git.ShowRepoFileAsync(repo.Path, "src/new.cs", mergeBase, ct)).Value); @@ -419,6 +420,67 @@ public async Task Counts_the_lines_each_change_adds_and_removes_uncommitted_edit Assert.True(counts["img.bin"].Binary); } + [Fact] + public async Task Untracked_files_are_compared_with_the_base_like_tracked_ones() + { + using var repo = new TempRepo(init: true); + repo.Write(".gitignore", "*.log\n"); + repo.Commit("restored.cs", "back again\n", "Base"); + repo.Commit("still-gone.cs", "bye\n", "Add still gone"); + repo.Commit("differs.cs", "old\n", "Add differs"); + repo.Git("checkout", "-q", "-b", "feature"); + repo.Git("rm", "-q", "restored.cs", "still-gone.cs", "differs.cs"); + repo.Git("-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", + "commit", "-q", "-m", "Delete them"); + repo.Write("restored.cs", "back again\n"); + repo.Write("differs.cs", "new\nlines\n"); + repo.Write("sub dir/new file.cs", "one\ntwo\nthree\n"); + repo.Write("ünïcode.cs", "é\n"); + repo.Write("debug.log", "ignored\n"); + System.IO.File.WriteAllBytes(repo.File("img.bin"), [0, 1, 2, 0]); + var git = new GitCli(new FileSystem()); + var ct = TestContext.Current.CancellationToken; + var mergeBase = (await git.GetMergeBaseAsync(repo.Path, "HEAD", "main", ct)).Value!; + + var changes = (await git.GetChangedFilesAsync(repo.Path, mergeBase, ct)).Value; + var counts = (await git.GetLineCountsAsync(repo.Path, mergeBase, ct)).Value; + + Assert.Equal( + [ + new GitChange(GitChangeKind.Added, ".gitignore"), + new GitChange(GitChangeKind.Modified, "differs.cs"), + new GitChange(GitChangeKind.Added, "img.bin"), + new GitChange(GitChangeKind.Deleted, "still-gone.cs"), + new GitChange(GitChangeKind.Added, "sub dir/new file.cs"), + new GitChange(GitChangeKind.Added, "ünïcode.cs"), + ], changes.OrderBy(c => c.Path, StringComparer.Ordinal)); + Assert.Equal(new GitLineCount(2, 1), counts["differs.cs"]); + Assert.Equal(new GitLineCount(3, 0), counts["sub dir/new file.cs"]); + Assert.Equal(new GitLineCount(1, 0), counts["ünïcode.cs"]); + Assert.True(counts["img.bin"].Binary); + + repo.Git("add", "-A"); + Assert.Equal(counts, (await git.GetLineCountsAsync(repo.Path, mergeBase, ct)).Value); + } + + [Fact] + public async Task Listing_untracked_files_leaves_the_index_alone() + { + using var repo = new TempRepo(init: true); + repo.Commit("a.cs", "a\n", "Base"); + repo.Git("checkout", "-q", "-b", "feature"); + repo.Write("new.cs", "new\n"); + var index = repo.File(".git/index"); + var before = System.IO.File.ReadAllBytes(index); + var git = new GitCli(new FileSystem()); + var ct = TestContext.Current.CancellationToken; + + Assert.Single((await git.GetChangedFilesAsync(repo.Path, "main", ct)).Value); + Assert.Single((await git.GetLineCountsAsync(repo.Path, "main", ct)).Value); + + Assert.Equal(before, System.IO.File.ReadAllBytes(index)); + } + [Fact] public async Task The_default_branch_is_origin_HEAD_then_main_then_master() {