Skip to content
Open
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
36 changes: 36 additions & 0 deletions docs/compact-folders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Compact folder paths

The **File System Tree** item in the Changes display-mode menu has a
**Compact single-child folders** submenu checkbox. Clicking the parent selects
tree mode; hovering or pressing Right Arrow opens the submenu. Toggling the
checkbox does not change the selected display mode. The three parent items
retain their original icons.
It uses the existing persisted preference (also available in Preferences > General),
so it applies consistently to working-copy changes, selected commits, comparisons
and stashes. Existing preferences and the default remain unchanged.

The historical **Files** tab now supports the same option through its tree button
beside Search. A path such as `src/main/java/example` becomes one folder row,
`src / main / java / example`; files stay separate. Branches and folders containing
both files and folders are not merged. Full-path tooltips, keyboard navigation and
file actions continue using actual Git object paths, not display labels.

Historical folders are loaded on demand. Compaction looks ahead only through
single-folder chains; it does not recursively enumerate branching subtrees.
Original nodes, loaded children and expansion state are retained when switching
between compact and regular views. Query results are shared for repeated loads.

## Verification

```sh
dotnet run --project tests/CompactFolders/CompactFolders.csproj
dotnet run --project tests/ChangeViewModeMenu -c Release -p:DisableAOT=true
dotnet build src/SourceGit.csproj -c Release -p:DisableAOT=true
```

Manual UI checks: toggle from each Changes display-mode menu; expand/collapse deep
chains with Left/Right keys; copy a compact folder's path; open/copy/save a file;
search for a deep file, toggle compaction, and clear search; switch revisions and
toggle repeatedly while files load. Framework-free tests exercise production tree
models. The Avalonia Headless harness checks menu pointer and keyboard input;
full historical-file navigation still requires runtime UI verification.
106 changes: 106 additions & 0 deletions docs/selected-commit-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Selected-commit change summary

Select two or more commits in History (Ctrl/Cmd-click for individual commits,
Shift-click for a range). The Changes tab shows their joined file changes and
loads the selected file's diff. All three detail tabs remain available:

* **Information** lists exactly the selected commits in displayed history order,
in bordered cards using the same full information view as a single commit:
metadata, full message, signature, links and changed-file preview. Cards load
information on demand with bounded concurrency; offscreen cards are virtualized.
* **Changes** keeps the joined summary for the entire history selection.
* **Files** reuses the single-commit historical file browser: full tree, search,
previews, open/save and applicable existing file actions. It defaults to the
topmost selected history commit, not a synthetic combined tree. Choose a card
in Information or use the SHA/subject dropdown in Files to browse another
selected revision; this never changes the outer history selection or summary.

The file browser is loaded lazily, and changing its revision replaces/disposes
the old detail context. Late tree/search/preview results cannot overwrite a
newer revision or file selection. Search also matches the original name of a
renamed file. Selecting one commit restores the existing commit detail view.

For the previous two-endpoint comparison, select exactly two commits and choose
**Compare these two revisions (not selected changes)** from the context menu.
This is intentionally distinct from the selected-commit summary.

## What is aggregated

The implementation follows the path-based “zip changes” model used by Rebased:

* Process only selected commits, in oldest-to-newest displayed history order.
Clicking the same commits in a different order does not reverse their changes.
* Join a later change to the earliest existing change whose destination is the
later change's source path. Keep the first before endpoint and last after
endpoint. Rename chains therefore retain their initial and final names.
* Add then delete cancels. Delete then add remains two independent entries.
Reverting to identical content keeps an entry, whose content diff is empty.
* A merge contributes only paths changed relative to **every** parent. Diff
content uses the first parent's real before endpoint. Different-source merge
renames are displayed as modifications while retaining a valid before source.

This is **not** a diff of the oldest parent's whole tree against the newest
commit, and it does **not** replay/cherry-pick patches into a synthetic tree.
Files changed only by skipped commits do not appear. When selected commits touch
the same file, their before/after comparison can include intervening unselected
edits to that file. Path-based joining across unrelated branches has the same
limitation; the result is not a hypothetical merge or cherry-pick result.

Rebased can additionally present specialized per-parent/three-way merge views.
This feature provides SourceGit's existing two-pane/unified diff presentation,
not a new three-way merge viewer.

## Safety and lifecycle

All summary queries are read-only Git commands. They do not change HEAD, refs,
the index or working files, and do not create temporary commits or objects.
Git output is NUL-delimited, preserving tabs, newlines, quotes and Unicode in
paths. Exact blob endpoints prevent unrelated occupants of old/new renamed paths
from appearing in a file diff. Absent endpoints never mean the working copy.

Changing selection or closing the repository cancels pending summary queries.
Disposed view models reject queued success/error callbacks. Filtering uses the
latest UI value after loading, and hiding a selected file clears/replaces its
content rather than keeping stale content on screen. Diff refreshes also use a
generation guard for repeated context/whitespace changes.

Summary context menus intentionally offer only external diff and copy path;
reset/check-out and aggregate patch export are not exposed because selected
changes do not share one pair of tree revisions. External diff is disabled for
Gitlinks (submodules); their internal historical revision display remains
available.

## Verification

Run the framework-free production-code regression harness:

```sh
dotnet run --project tests/SelectedCommitChanges/SelectedCommitChanges.csproj
```

Build the app (including compiled Avalonia bindings):

```sh
dotnet build src/SourceGit.csproj -c Debug -m:1 -p:UseSharedCompilation=false
```

Run the upstream formatting check:

```sh
dotnet format --verify-no-changes src/SourceGit.csproj
```

The harness exercises production query and aggregate view-model code, but stubs
Avalonia dispatch and individual commit details. It does not replace runtime UI
checks. Manually check Ctrl/Cmd and Shift selection, each detail tab, repeated
card/revision navigation, search, file actions, and selection changes while data
is loading. This feature adds no runtime NuGet dependencies.

## Known rendering edge case

Same-path endpoints retain Git's path-aware diff and `.gitattributes` behavior.
Renamed endpoints use exact blobs so unrelated files occupying either name cannot
leak into the diff. That blob comparison does not apply filename-based attribute
diff drivers/classification; for example an ASCII file explicitly marked `-diff`
may appear as text after a rename. Ordinary NUL-detected binary files remain
binary. This limitation does not change which selected paths are aggregated.
13 changes: 13 additions & 0 deletions src/Commands/Diff.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ public partial class Diff : Command
public Diff(string repo, Models.DiffOption opt, int numContextLines, bool ignoreWhitespace, bool ignoreCRAtEOL)
{
_result.TextDiff = new Models.TextDiff();
if (opt.CommitChange is { } joined &&
(string.IsNullOrEmpty(joined.BeforeObject) || joined.BeforeMode == "160000") &&
(string.IsNullOrEmpty(joined.AfterObject) || joined.AfterMode == "160000"))
{
_result.IsSubmoduleChange = true;
_result.OldHash = joined.BeforeObject;
_result.NewHash = joined.AfterObject;
_result.TextDiff = null;
}

WorkingDirectory = repo;
Context = repo;
Expand All @@ -59,6 +68,10 @@ public Diff(string repo, Models.DiffOption opt, int numContextLines, bool ignore

public async Task<Models.DiffResult> ReadAsync()
{
// Gitlink targets need not exist in the superproject object database.
if (_result.IsSubmoduleChange)
return _result;

try
{
using var proc = new Process();
Expand Down
180 changes: 180 additions & 0 deletions src/Commands/QuerySelectedCommitChanges.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace SourceGit.Commands
{
public class QuerySelectedCommitChanges : Command
{
public QuerySelectedCommitChanges(string repo)
{
WorkingDirectory = repo;
Context = repo;
Editor = EditorType.None;
}

/// <param name="commits">Selected commits in history order, newest first.</param>
public async Task<List<Models.CommitChange>> ReadAsync(IReadOnlyList<Models.Commit> commits, CancellationToken cancellationToken)
{
var changes = new List<Models.CommitChange>();
for (var i = commits.Count - 1; i >= 0; i--)
{
cancellationToken.ThrowIfCancellationRequested();
var commit = commits[i];
var parentChanges = new List<List<Models.CommitChange>>();
if (commit.Parents.Count == 0)
{
// --root works without writing an empty tree into the object database.
Args = $"diff-tree --root --no-commit-id --raw -z -r -M --no-ext-diff --ignore-submodules=none --no-abbrev {commit.SHA}";
var output = await ReadRawAsync(cancellationToken).ConfigureAwait(false);
parentChanges.Add(Parse(output, commit.FirstParentToCompare, commit.SHA));
}
else
{
foreach (var parent in commit.Parents)
{
cancellationToken.ThrowIfCancellationRequested();
Args = $"diff-tree --no-commit-id --raw -z -r -M --no-ext-diff --ignore-submodules=none --no-abbrev {parent} {commit.SHA}";
var output = await ReadRawAsync(cancellationToken).ConfigureAwait(false);
parentChanges.Add(Parse(output, parent, commit.SHA));
}
}

changes.AddRange(MergeParents(parentChanges));
}

cancellationToken.ThrowIfCancellationRequested();
return Models.CommitChange.Zip(changes);
}

/// <summary>
/// Combined merge changes, as in the Rebased log: a path must differ from
/// every parent. Renames are matched by destination, not source path.
/// Retain the first parent's real endpoints for content loading.
/// </summary>
public static List<Models.CommitChange> MergeParents(IReadOnlyList<List<Models.CommitChange>> parents)
{
if (parents.Count == 0)
return [];
if (parents.Count == 1)
return parents[0];

var maps = new List<Dictionary<string, Models.CommitChange>>();
for (var i = 1; i < parents.Count; i++)
{
var map = new Dictionary<string, Models.CommitChange>(StringComparer.Ordinal);
foreach (var change in parents[i])
map[change.Path] = change;
maps.Add(map);
}

var result = new List<Models.CommitChange>();
foreach (var first in parents[0])
{
var presentInAll = true;
var status = first.Index;
foreach (var map in maps)
{
if (!map.TryGetValue(first.Path, out var other))
{
presentInAll = false;
break;
}

if (status != other.Index)
status = status == Models.ChangeState.Deleted || other.Index == Models.ChangeState.Deleted ?
Models.ChangeState.Deleted : Models.ChangeState.Modified;
else if (status == Models.ChangeState.Renamed && !first.BeforePath.Equals(other.BeforePath, StringComparison.Ordinal))
status = Models.ChangeState.Modified;
}

if (!presentInAll)
continue;

first.Index = status;
if (status != Models.ChangeState.Renamed)
first.OriginalPath = string.Empty;
result.Add(first);
}

return result;
}

public static List<Models.CommitChange> Parse(string output, string beforeRevision, string afterRevision)
{
var result = new List<Models.CommitChange>();
var fields = output.Split('\0');
for (var i = 0; i < fields.Length - 1;)
{
var header = fields[i++].Split(' ');
if (header.Length != 5 || !header[0].StartsWith(':') || i >= fields.Length - 1)
throw new InvalidDataException("Invalid raw diff from Git.");

var path = fields[i++];
var status = header[4][0];
var target = path;
if (status is 'R' or 'C')
{
if (i >= fields.Length - 1)
throw new InvalidDataException("Missing rename destination from Git.");
target = fields[i++];
}

var change = new Models.CommitChange
{
BeforeRevision = status == 'A' ? string.Empty : beforeRevision,
BeforePath = status == 'A' ? string.Empty : path,
BeforeMode = header[0][1..],
BeforeObject = status == 'A' ? string.Empty : header[2],
AfterRevision = status == 'D' ? string.Empty : afterRevision,
AfterPath = status == 'D' ? string.Empty : target,
AfterMode = header[1],
AfterObject = status == 'D' ? string.Empty : header[3],
};
change.UpdateStatus();
result.Add(change);
}

return result;
}

private async Task<string> ReadRawAsync(CancellationToken cancellationToken)
{
using var process = new Process { StartInfo = CreateGitStartInfo(true) };
process.Start();
// Drain both pipes concurrently, and own the process lifetime when a
// newer selection cancels this read-only query.
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try
{
if (!process.HasExited)
process.Kill(true);
}
catch (InvalidOperationException)
{
// The process may exit between HasExited and Kill.
}
await process.WaitForExitAsync().ConfigureAwait(false);
await Task.WhenAll(stdout, stderr).ConfigureAwait(false);
throw;
}

var output = await stdout.ConfigureAwait(false);
var error = await stderr.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (process.ExitCode != 0)
throw new InvalidOperationException(error.Trim());
return output;
}
}
}
Loading