Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <merge base>` 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 <merge base>` 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 `<merge base>:<path>`, 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.
Expand Down
2 changes: 1 addition & 1 deletion src/TuiCode.Abstractions/IGitCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public interface IGitCli
/// <summary>The full hash of the best common ancestor of two revisions, or null when they share no history.</summary>
Task<GitResult<string?>> GetMergeBaseAsync(string path, string first, string second, CancellationToken cancellationToken = default);

/// <summary>Files that differ between <paramref name="revision"/> and the working copy, staged or not, with repo-relative paths.</summary>
/// <summary>Files that differ between <paramref name="revision"/> and the working copy, staged, unstaged or untracked and not ignored, with repo-relative paths.</summary>
Task<GitResult<IReadOnlyList<GitChange>>> GetChangedFilesAsync(string path, string revision, CancellationToken cancellationToken = default);

/// <summary>The lines each file in <see cref="GetChangedFilesAsync"/> adds and removes, keyed by its (new) repo-relative path.</summary>
Expand Down
32 changes: 29 additions & 3 deletions src/TuiCode.Workbench/Git/GitCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ public async Task<GitResult<IReadOnlyList<GitChange>>> GetChangedFilesAsync(stri
if (!IsSafeRevision(revision))
return GitResult<IReadOnlyList<GitChange>>.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<IReadOnlyList<GitChange>>.Success(ParseChanges(run.Output))
Expand All @@ -221,13 +221,36 @@ public async Task<GitResult<IReadOnlyDictionary<string, GitLineCount>>> GetLineC
if (!IsSafeRevision(revision))
return GitResult<IReadOnlyDictionary<string, GitLineCount>>.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<IReadOnlyDictionary<string, GitLineCount>>.Success(ParseLineCounts(run.Output))
: GitResult<IReadOnlyDictionary<string, GitLineCount>>.Failure(ErrorMessage(run));
}

/// <summary>Diffs with untracked, not-ignored files added as intent-to-add to a throwaway copy of the index.</summary>
private async Task<CliRun> 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<GitResult<bool>> AddWorktreeAsync(string repoRoot, string worktreePath, CancellationToken cancellationToken = default)
{
if (fileSystem.Directory.Exists(worktreePath))
Expand Down Expand Up @@ -486,7 +509,8 @@ private static string ErrorMessage(CliRun run)
}

private Task<CliRun> RunAsync(
string workingDirectory, IEnumerable<string> arguments, CancellationToken cancellationToken, TimeSpan? timeout = null, string? input = null)
string workingDirectory, IEnumerable<string> arguments, CancellationToken cancellationToken, TimeSpan? timeout = null, string? input = null,
string? indexFile = null)
{
var info = new ProcessStartInfo(executable)
{
Expand All @@ -506,6 +530,8 @@ private Task<CliRun> 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);
}
Expand Down
62 changes: 62 additions & 0 deletions tests/TuiCode.Tests/GitCliTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()
{
Expand Down
Loading