diff --git a/.github/README.md b/.github/README.md
index 1f1f17e..cbdeeec 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -56,6 +56,7 @@ Kontext lets you work on all your repositories' issues at once and leave Claude
* **Know when Claude needs you.** Claude Code hooks report whether a session is working or waiting for input; the circle in the sidebar changes accordingly.
* **Resume where you left off.** Sessions are remembered per issue and continued with `claude --resume` after a restart.
* **Open it anywhere.** Open a worktree in File Explorer, Windows Terminal or its solution file (`.slnx` or `.sln`, in whatever app is registered for it) from the issue's header.
+* **Commit and push from the app.** The git tab lists the worktree's changes with a checkbox per file; commit the selected files with a message and push the branch to `origin` without leaving Kontext.
* **Clean up when done.** For a closed issue, Clear session removes the worktree and its branch.
* **Modern Architecture:** Built with SOLID principles and design patterns for maximum maintainability: a modular monolith with compiler-enforced module boundaries.
* **Native Windows look.** WinUI 3 with Mica, light and dark themes, and a terminal with zoom and mouse support.
diff --git a/src/Kontext.Contracts/IWorkspaceCommitter.cs b/src/Kontext.Contracts/IWorkspaceCommitter.cs
new file mode 100644
index 0000000..e43c6e3
--- /dev/null
+++ b/src/Kontext.Contracts/IWorkspaceCommitter.cs
@@ -0,0 +1,16 @@
+using Kontext.Domain;
+
+namespace Kontext.Contracts;
+
+/// Commits and pushes the changes of one worktree.
+public interface IWorkspaceCommitter
+{
+ /// Commits exactly the given changes with the message; other staged or unstaged files are left alone.
+ Task CommitAsync(string worktreePath, IReadOnlyList changes, string message, CancellationToken cancellationToken = default);
+
+ /// Pushes the checked-out branch to origin, setting it as the upstream.
+ Task PushAsync(string worktreePath, CancellationToken cancellationToken = default);
+
+ /// The number of commits on the checked-out branch that no remote has yet.
+ Task CountUnpushedAsync(string worktreePath, CancellationToken cancellationToken = default);
+}
diff --git a/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs b/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs
index 9ecd11e..2019553 100644
--- a/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs
+++ b/src/Kontext.GitAutomation/ServiceCollectionExtensions.cs
@@ -14,6 +14,7 @@ public static IServiceCollection AddKontextGitAutomation(this IServiceCollection
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Kontext.GitAutomation/WorkspaceCommitter.cs b/src/Kontext.GitAutomation/WorkspaceCommitter.cs
new file mode 100644
index 0000000..b4fc9bd
--- /dev/null
+++ b/src/Kontext.GitAutomation/WorkspaceCommitter.cs
@@ -0,0 +1,80 @@
+using System.Globalization;
+using Kontext.Contracts;
+using Kontext.Domain;
+
+namespace Kontext.GitAutomation;
+
+internal sealed class WorkspaceCommitter(IGitProcess git) : IWorkspaceCommitter
+{
+ private const string Literal = "--literal-pathspecs";
+
+ public async Task CommitAsync(string worktreePath, IReadOnlyList changes, string message, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(worktreePath);
+ ArgumentNullException.ThrowIfNull(changes);
+ ArgumentException.ThrowIfNullOrWhiteSpace(message);
+
+ var paths = changes
+ .SelectMany(change => change.OriginalPath is { } original ? new[] { change.Path, original } : [change.Path])
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (paths.Count == 0)
+ {
+ throw new GitAutomationException("Nothing to commit: no files are selected.", 0, string.Empty);
+ }
+
+ // Only the current paths are added: a rename's original path is already gone from the index and the worktree.
+ var added = changes.Select(change => change.Path).Distinct(StringComparer.Ordinal).ToList();
+ await git.RunCheckedAsync(worktreePath, [Literal, "add", "-A", "--", .. added], cancellationToken);
+
+ // Exit code 0 means the selected paths have nothing staged, so a commit would fail or be empty.
+ var staged = await git.RunAsync(worktreePath, [Literal, "diff", "--cached", "--quiet", "--", .. paths], cancellationToken);
+ if (staged.Succeeded)
+ {
+ throw new GitAutomationException("Nothing to commit: the selected files have no changes.", 0, string.Empty);
+ }
+
+ // A pathspec makes git commit only those paths, so other staged files stay staged and out of this commit.
+ await git.RunCheckedAsync(worktreePath, [Literal, "commit", "-m", message, "--", .. paths], cancellationToken);
+ }
+
+ public async Task PushAsync(string worktreePath, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(worktreePath);
+
+ var result = await git.RunAsync(worktreePath, ["push", "--set-upstream", "origin", "HEAD"], cancellationToken);
+ if (!result.Succeeded)
+ {
+ throw new GitAutomationException(DescribePushFailure(result), result.ExitCode, result.StandardError);
+ }
+ }
+
+ public async Task CountUnpushedAsync(string worktreePath, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(worktreePath);
+
+ // Counts commits reachable from HEAD that no remote-tracking branch has; it works before an upstream exists.
+ var result = await git.RunAsync(worktreePath, ["--no-optional-locks", "rev-list", "--count", "HEAD", "--not", "--remotes"], cancellationToken);
+ return result.Succeeded && int.TryParse(result.Output, NumberStyles.None, CultureInfo.InvariantCulture, out var count) ? count : 0;
+ }
+
+ private static string DescribePushFailure(GitResult result)
+ {
+ var error = result.StandardError;
+ if (Contains(error, "non-fast-forward") || Contains(error, "[rejected]") || Contains(error, "fetch first"))
+ {
+ return "Push rejected: the remote branch has commits you do not have. Pull or rebase, then push again.";
+ }
+
+ if (Contains(error, "Authentication failed") || Contains(error, "could not read Username") || Contains(error, "Permission denied")
+ || Contains(error, "returned error: 403") || Contains(error, "returned error: 401"))
+ {
+ return "Push failed: authentication failed. Sign in to GitHub again and retry.";
+ }
+
+ var detail = string.IsNullOrWhiteSpace(error) ? result.StandardOutput.Trim() : error.Trim();
+ return $"Push failed: {detail}";
+ }
+
+ private static bool Contains(string text, string value) => text.Contains(value, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Kontext.Presentation/GitChangeRow.cs b/src/Kontext.Presentation/GitChangeRow.cs
index 38e18c8..5e1a591 100644
--- a/src/Kontext.Presentation/GitChangeRow.cs
+++ b/src/Kontext.Presentation/GitChangeRow.cs
@@ -1,13 +1,44 @@
+using System.ComponentModel;
using System.Globalization;
using Kontext.Domain;
namespace Kontext.Presentation;
/// One line of the change list: either a group header ("Staged (2)") or a changed file.
-public sealed record GitChangeRow(string Text, FileChange? Change)
+public sealed record GitChangeRow(string Text, FileChange? Change) : INotifyPropertyChanged
{
+ private bool _isChecked;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
public bool IsHeader => Change is null;
+ /// Whether the file is included in the next commit. Called by the list when the user toggles it.
+ public bool IsChecked
+ {
+ get => _isChecked;
+ set
+ {
+ if (_isChecked == value)
+ {
+ return;
+ }
+
+ _isChecked = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked)));
+ Toggled?.Invoke(this, value);
+ }
+ }
+
+ /// Starts the row checked without reporting a toggle.
+ public bool InitiallyChecked
+ {
+ init => _isChecked = value;
+ }
+
+ /// Told when the user checks or unchecks the row.
+ public Action? Toggled { get; init; }
+
public string StatusLetter => Change?.Status switch
{
FileChangeStatus.Modified => "M",
@@ -28,4 +59,14 @@ public sealed record GitChangeRow(string Text, FileChange? Change)
public string AdditionsText => Change?.Additions is { } additions ? string.Create(CultureInfo.InvariantCulture, $"+{additions}") : string.Empty;
public string DeletionsText => Change?.Deletions is { } deletions ? string.Create(CultureInfo.InvariantCulture, $"−{deletions}") : string.Empty;
+
+ /// Sets the checked state from the view model, so the box follows without reporting a toggle.
+ internal void SetCheckedFromModel(bool value)
+ {
+ if (_isChecked != value)
+ {
+ _isChecked = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked)));
+ }
+ }
}
diff --git a/src/Kontext.Presentation/GitChangesViewModel.cs b/src/Kontext.Presentation/GitChangesViewModel.cs
index fc25052..e23dc1a 100644
--- a/src/Kontext.Presentation/GitChangesViewModel.cs
+++ b/src/Kontext.Presentation/GitChangesViewModel.cs
@@ -1,19 +1,26 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
using Kontext.Contracts;
using Kontext.Domain;
namespace Kontext.Presentation;
///
-/// The uncommitted changes of one worktree. It reloads when it becomes active and then polls, but only while
-/// and a are both set. Await continuations return to the
-/// thread that set those properties (the UI thread), so no dispatcher is needed.
+/// The uncommitted changes of one worktree, and committing and pushing them. It reloads when it becomes active and
+/// then polls, but only while and a are both set. Await continuations
+/// return to the thread that set those properties (the UI thread), so no dispatcher is needed.
///
-public sealed partial class GitChangesViewModel(IWorkspaceChanges changes, TimeProvider clock, GitChangesOptions options) : ObservableObject, IDisposable
+public sealed partial class GitChangesViewModel(IWorkspaceChanges changes, IWorkspaceCommitter committer, TimeProvider clock, GitChangesOptions options) : ObservableObject, IDisposable
{
+ public const string CommittingText = "Committing…";
+
+ public const string PushingText = "Pushing…";
+
private readonly LatestOnly _reloads = new();
private readonly LatestOnly _selections = new();
+ private readonly CancellationTokenSource _lifetime = new();
+ private readonly HashSet _checked = new(StringComparer.Ordinal);
private CancellationTokenSource? _loop;
private WorkingTreeChanges _last = WorkingTreeChanges.Empty;
private FileChange? _selectedChange;
@@ -49,6 +56,69 @@ public sealed partial class GitChangesViewModel(IWorkspaceChanges changes, TimeP
[NotifyPropertyChangedFor(nameof(IsEmpty))]
public partial bool HasLoaded { get; set; }
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(CommitCommand))]
+ public partial string CommitMessage { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(CommitCommand), nameof(PushCommand))]
+ public partial bool IsBusy { get; set; }
+
+ /// What the running commit or push is doing, e.g. "Pushing…".
+ [ObservableProperty]
+ public partial string BusyText { get; set; } = string.Empty;
+
+ /// Why the last commit or push failed. Kept apart from so a successful poll does not hide it.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasActionError))]
+ public partial string? ActionError { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasStatusMessage))]
+ public partial string? StatusMessage { get; set; }
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(PushCommand))]
+ public partial int UnpushedCount { get; set; }
+
+ public int CheckedCount => _checked.Count;
+
+ /// The "all files" box: checked when every changed file is selected, unchecked when none is, otherwise undetermined.
+ public bool? AllChecked
+ {
+ get
+ {
+ var total = DistinctPaths().Count();
+ return total == 0 || _checked.Count == 0 ? false : _checked.Count >= total ? true : null;
+ }
+ set
+ {
+ // The view writes the value it just read back (undetermined while only some files are checked); that is not a
+ // request to change the selection. Only the user checking or unchecking the box is.
+ if (IsBusy || value is null || value == AllChecked)
+ {
+ return;
+ }
+
+ _checked.Clear();
+ if (value == true)
+ {
+ _checked.UnionWith(DistinctPaths());
+ }
+
+ foreach (var row in Rows)
+ {
+ row.SetCheckedFromModel(row.Change is { } change && _checked.Contains(change.Path));
+ }
+
+ RaiseCheckedChanged();
+ }
+ }
+
+ public bool HasActionError => !string.IsNullOrEmpty(ActionError);
+
+ public bool HasStatusMessage => !string.IsNullOrEmpty(StatusMessage);
+
public bool HasChanges => _last.Staged.Count + _last.Unstaged.Count > 0;
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
@@ -73,7 +143,13 @@ public FileChange? SelectedChange
public void Dispose()
{
- _disposed = true;
+ if (!_disposed)
+ {
+ _disposed = true;
+ _lifetime.Cancel();
+ _lifetime.Dispose();
+ }
+
StopLoop();
_reloads.Dispose();
_selections.Dispose();
@@ -94,12 +170,14 @@ public async Task ReloadAsync()
{
// Starting git and reading files must not happen on the UI thread; the continuation comes back to it.
var result = await Task.Run(() => changes.GetChangesAsync(path, token), token);
+ var unpushed = await Task.Run(() => committer.CountUnpushedAsync(path, token), token);
if (!IsCurrent(operation, path))
{
return;
}
Apply(result);
+ UnpushedCount = unpushed;
ErrorMessage = null;
HasLoaded = true;
await LoadSelectedDiffAsync(token);
@@ -123,6 +201,13 @@ partial void OnWorktreePathChanged(string? value)
StopLoop();
_last = WorkingTreeChanges.Empty;
+ _checked.Clear();
+ RaiseCheckedChanged();
+ CommitCommand.NotifyCanExecuteChanged();
+ CommitMessage = string.Empty;
+ ActionError = null;
+ StatusMessage = null;
+ UnpushedCount = 0;
Rows = [];
Summary = string.Empty;
ErrorMessage = null;
@@ -180,7 +265,11 @@ private async Task RunLoopAsync(CancellationToken token)
await ReloadAsync();
while (await timer.WaitForNextTickAsync(token))
{
- await ReloadAsync();
+ // A commit or push reloads when it is done; reading git halfway through would show a transient state.
+ if (!IsBusy)
+ {
+ await ReloadAsync();
+ }
}
}
catch (OperationCanceledException)
@@ -193,7 +282,15 @@ private void Apply(WorkingTreeChanges result)
if (!result.Equals(_last))
{
_last = result;
+ var present = result.Staged.Concat(result.Unstaged).Select(change => change.Path).ToHashSet(StringComparer.Ordinal);
+ if (_checked.RemoveWhere(path => !present.Contains(path)) > 0)
+ {
+ RaiseCheckedChanged();
+ CommitCommand.NotifyCanExecuteChanged();
+ }
+
Rows = BuildRows(result);
+ OnPropertyChanged(nameof(AllChecked));
Summary = BuildSummary(result);
OnPropertyChanged(nameof(HasChanges));
OnPropertyChanged(nameof(IsEmpty));
@@ -262,7 +359,113 @@ private async Task LoadSelectedDiffAsync(CancellationToken token)
}
}
- private static List BuildRows(WorkingTreeChanges result)
+ private IEnumerable DistinctPaths()
+ => _last.Staged.Concat(_last.Unstaged).Select(change => change.Path).Distinct(StringComparer.Ordinal);
+
+ private void RaiseCheckedChanged()
+ {
+ OnPropertyChanged(nameof(CheckedCount));
+ OnPropertyChanged(nameof(AllChecked));
+ CommitCommand.NotifyCanExecuteChanged();
+ }
+
+ private bool CanCommit => !IsBusy && _checked.Count > 0 && !string.IsNullOrWhiteSpace(CommitMessage);
+
+ private bool CanPush => !IsBusy && UnpushedCount > 0;
+
+ [RelayCommand(CanExecute = nameof(CanCommit))]
+ private async Task CommitAsync()
+ {
+ var selection = _last.Staged.Concat(_last.Unstaged).Where(change => _checked.Contains(change.Path)).ToList();
+ var message = CommitMessage.Trim();
+ var files = selection.Select(change => change.Path).Distinct(StringComparer.Ordinal).Count();
+ var noun = files == 1 ? "file" : "files";
+
+ await RunActionAsync(
+ CommittingText,
+ (path, token) => committer.CommitAsync(path, selection, message, token),
+ () =>
+ {
+ CommitMessage = string.Empty;
+ _checked.Clear();
+ RaiseCheckedChanged();
+ Rows = BuildRows(_last);
+ return string.Create(CultureInfo.InvariantCulture, $"Committed {files} {noun}.");
+ });
+ }
+
+ [RelayCommand(CanExecute = nameof(CanPush))]
+ private Task PushAsync()
+ => RunActionAsync(PushingText, (path, token) => committer.PushAsync(path, token), () => "Pushed to origin.");
+
+ // Git runs off the UI thread. A failure is shown and leaves the message and selection as they were, so the user can retry.
+ private async Task RunActionAsync(string busyText, Func action, Func onSuccess)
+ {
+ var path = WorktreePath;
+ if (_disposed || string.IsNullOrEmpty(path))
+ {
+ return;
+ }
+
+ IsBusy = true;
+ BusyText = busyText;
+ ActionError = null;
+ StatusMessage = null;
+ var token = _lifetime.Token;
+
+ try
+ {
+ await Task.Run(() => action(path, token), token);
+ if (WorktreePath == path)
+ {
+ StatusMessage = onSuccess();
+ }
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ }
+#pragma warning disable CA1031 // Any git failure is shown to the user.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ if (WorktreePath == path)
+ {
+ ActionError = ex.Message;
+ }
+ }
+ finally
+ {
+ IsBusy = false;
+ BusyText = string.Empty;
+ }
+
+ await ReloadAsync();
+ }
+
+ private void SetChecked(GitChangeRow row, bool value)
+ {
+ if (row.Change is not { } change)
+ {
+ return;
+ }
+
+ if (value ? _checked.Add(change.Path) : _checked.Remove(change.Path))
+ {
+ // The same file can be listed as both staged and changed; keep both boxes in step.
+ foreach (var other in Rows)
+ {
+ if (!ReferenceEquals(other, row) && other.Change?.Path == change.Path)
+ {
+ other.SetCheckedFromModel(value);
+ }
+ }
+
+ RaiseCheckedChanged();
+ CommitCommand.NotifyCanExecuteChanged();
+ }
+ }
+
+ private List BuildRows(WorkingTreeChanges result)
{
var rows = new List();
AddGroup(rows, "Staged", result.Staged);
@@ -270,7 +473,7 @@ private static List BuildRows(WorkingTreeChanges result)
return rows;
}
- private static void AddGroup(List rows, string title, IReadOnlyList group)
+ private void AddGroup(List rows, string title, IReadOnlyList group)
{
if (group.Count == 0)
{
@@ -278,7 +481,11 @@ private static void AddGroup(List rows, string title, IReadOnlyLis
}
rows.Add(new GitChangeRow(string.Create(CultureInfo.InvariantCulture, $"{title} ({group.Count})"), null));
- rows.AddRange(group.Select(change => new GitChangeRow(change.Path, change)));
+ rows.AddRange(group.Select(change => new GitChangeRow(change.Path, change)
+ {
+ InitiallyChecked = _checked.Contains(change.Path),
+ Toggled = SetChecked,
+ }));
}
private static string BuildSummary(WorkingTreeChanges result)
diff --git a/src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs b/src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs
index 9879752..7ea2452 100644
--- a/src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs
+++ b/src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs
@@ -16,6 +16,11 @@ public static IServiceCollection AddKontextUiTestFakes(this IServiceCollection s
services.Replace(ServiceDescriptor.Singleton());
services.Replace(ServiceDescriptor.Singleton());
services.Replace(ServiceDescriptor.Singleton());
+
+ // One instance serves both contracts, so a commit is seen by the next change listing.
+ var workspace = new UiTestWorkspace();
+ services.Replace(ServiceDescriptor.Singleton(workspace));
+ services.Replace(ServiceDescriptor.Singleton(workspace));
return services;
}
}
diff --git a/src/Kontext/UiTesting/UiTestSessionCoordinator.cs b/src/Kontext/UiTesting/UiTestSessionCoordinator.cs
index a2dcae9..52114c2 100644
--- a/src/Kontext/UiTesting/UiTestSessionCoordinator.cs
+++ b/src/Kontext/UiTesting/UiTestSessionCoordinator.cs
@@ -4,7 +4,18 @@
namespace Kontext.UiTesting;
/// Never clones, starts Claude or opens a terminal: UI tests must not touch the machine.
-internal sealed class UiTestSessionCoordinator : IIssueSessionCoordinator
+internal sealed class UiTestSessionCoordinator(IIssueWorkTracker tracker) : IIssueSessionCoordinator
{
- public Task StartOrResumeAsync(GitHubIssue issue, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task StartOrResumeAsync(GitHubIssue issue, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(issue);
+
+ // One issue pretends to have a worktree, so its Git tab is available.
+ if (issue.Key == UiTestWorkspace.IssueWithWorktree)
+ {
+ tracker.Report(issue.Key, new IssueWorkState(IssueWorkStatus.Ready, "features/12-draw-the-coastline", UiTestWorkspace.WorktreePath));
+ }
+
+ return Task.CompletedTask;
+ }
}
diff --git a/src/Kontext/UiTesting/UiTestWorkspace.cs b/src/Kontext/UiTesting/UiTestWorkspace.cs
new file mode 100644
index 0000000..b5da728
--- /dev/null
+++ b/src/Kontext/UiTesting/UiTestWorkspace.cs
@@ -0,0 +1,83 @@
+using Kontext.Contracts;
+using Kontext.Domain;
+using Kontext.Kernel;
+
+namespace Kontext.UiTesting;
+
+///
+/// An in-memory worktree for the Git tab: three changed files that a commit removes from the list, and a counter of
+/// unpushed commits. A commit message containing "fail" makes the commit fail; one containing "reject" makes the next
+/// push fail once. Never touches git or the disk.
+///
+internal sealed class UiTestWorkspace : IWorkspaceChanges, IWorkspaceCommitter
+{
+ public const string WorktreePath = @"C:\ui-test\worktree";
+
+ public static IssueKey IssueWithWorktree { get; } = new("acme/atlas", 12);
+
+ private readonly object _lock = new();
+ private readonly List _changes =
+ [
+ Modified("src/Map.cs", 4, 1),
+ Modified("src/Legend.cs", 2, 0),
+ new("notes.txt", null, FileChangeStatus.Untracked, IsStaged: false, 1, 0, IsBinary: false),
+ ];
+
+ private int _unpushed;
+ private bool _rejectNextPush;
+
+ public Task GetChangesAsync(string worktreePath, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ return Task.FromResult(new WorkingTreeChanges([], [.. _changes]));
+ }
+ }
+
+ public Task GetDiffAsync(string worktreePath, FileChange change, CancellationToken cancellationToken = default)
+ => Task.FromResult(new FileDiff(change.Path, [new DiffLine(DiffLineKind.Added, null, 1, $"changed {change.Path}")]));
+
+ public Task CommitAsync(string worktreePath, IReadOnlyList changes, string message, CancellationToken cancellationToken = default)
+ {
+ if (message.Contains("fail", StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.FromException(new InvalidOperationException("Commit failed (simulated)."));
+ }
+
+ lock (_lock)
+ {
+ _changes.RemoveAll(existing => changes.Any(committed => committed.Path == existing.Path));
+ _unpushed++;
+ _rejectNextPush = message.Contains("reject", StringComparison.OrdinalIgnoreCase);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task PushAsync(string worktreePath, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ if (_rejectNextPush)
+ {
+ _rejectNextPush = false;
+ return Task.FromException(new InvalidOperationException("Push rejected (simulated)."));
+ }
+
+ _unpushed = 0;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task CountUnpushedAsync(string worktreePath, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ return Task.FromResult(_unpushed);
+ }
+ }
+
+ private static FileChange Modified(string path, int additions, int deletions)
+ => new(path, null, FileChangeStatus.Modified, IsStaged: false, additions, deletions, IsBinary: false);
+}
diff --git a/src/Kontext/Views/GitChangesView.xaml b/src/Kontext/Views/GitChangesView.xaml
index 0d9b035..66ead98 100644
--- a/src/Kontext/Views/GitChangesView.xaml
+++ b/src/Kontext/Views/GitChangesView.xaml
@@ -90,18 +90,26 @@
+
+
@@ -109,7 +117,7 @@
-
+
+
-
+ Margin="12,4,12,0"
+ Orientation="Horizontal"
+ Spacing="4"
+ Visibility="{x:Bind views:GitViewFormat.VisibleWhenText(ViewModel.Summary), Mode=OneWay}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Kontext/Views/GitChangesView.xaml.cs b/src/Kontext/Views/GitChangesView.xaml.cs
index 2cb49cd..96f3ae9 100644
--- a/src/Kontext/Views/GitChangesView.xaml.cs
+++ b/src/Kontext/Views/GitChangesView.xaml.cs
@@ -2,6 +2,7 @@
using System.Text;
using Kontext.Domain;
using Kontext.Presentation;
+using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
@@ -19,7 +20,12 @@ public sealed partial class GitChangesView : UserControl
private bool _syncing;
- public GitChangesView() => InitializeComponent();
+ public GitChangesView()
+ {
+ InitializeComponent();
+ Loaded += (_, _) => ApplyDisabledCommitColors();
+ ActualThemeChanged += (_, _) => ApplyDisabledCommitColors();
+ }
public GitChangesViewModel? ViewModel
{
@@ -27,6 +33,19 @@ public GitChangesViewModel? ViewModel
set => SetValue(ViewModelProperty, value);
}
+ // The disabled Commit button is the accent colour washed out towards grey, not plain grey and not a faded blue.
+ private void ApplyDisabledCommitColors()
+ {
+ var accent = new Windows.UI.ViewManagement.UISettings().GetColorValue(Windows.UI.ViewManagement.UIColorType.Accent);
+ var grey = ActualTheme == ElementTheme.Dark ? (byte)0x5A : (byte)0xB4;
+ byte Mix(byte channel) => (byte)((channel * 0.45) + (grey * 0.55));
+ var washedOut = Windows.UI.Color.FromArgb(0xFF, Mix(accent.R), Mix(accent.G), Mix(accent.B));
+
+ CommitButton.Resources["AccentButtonBackgroundDisabled"] = new SolidColorBrush(washedOut);
+ CommitButton.Resources["AccentButtonBorderBrushDisabled"] = new SolidColorBrush(washedOut);
+ CommitButton.Resources["AccentButtonForegroundDisabled"] = new SolidColorBrush(Colors.White) { Opacity = 0.75 };
+ }
+
private void OnViewModelChanged(GitChangesViewModel? oldValue, GitChangesViewModel? newValue)
{
if (oldValue is not null)
diff --git a/src/Kontext/Views/GitViewFormat.cs b/src/Kontext/Views/GitViewFormat.cs
index 79d32e2..8c82ce2 100644
--- a/src/Kontext/Views/GitViewFormat.cs
+++ b/src/Kontext/Views/GitViewFormat.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using Kontext.Domain;
+using Kontext.Presentation;
using Microsoft.UI.Xaml;
namespace Kontext.Views;
@@ -11,6 +12,11 @@ public static class GitViewFormat
public static Visibility VisibleWhenText(string? text) => string.IsNullOrEmpty(text) ? Visibility.Collapsed : Visibility.Visible;
+ public static string CommitLabel(string? busyText) => busyText == GitChangesViewModel.CommittingText ? busyText : "Commit";
+
+ public static string PushLabel(int unpushed, string? busyText)
+ => busyText == GitChangesViewModel.PushingText ? busyText : unpushed > 0 ? $"Push ({unpushed})" : "Push";
+
public static string Title(FileChange? change)
=> change is null
? string.Empty
diff --git a/src/Kontext/Views/IssueDetailView.xaml b/src/Kontext/Views/IssueDetailView.xaml
index 7f0747f..cfd35df 100644
--- a/src/Kontext/Views/IssueDetailView.xaml
+++ b/src/Kontext/Views/IssueDetailView.xaml
@@ -186,6 +186,7 @@
diff --git a/tests/Kontext.CompositionTests/CompositionTests.cs b/tests/Kontext.CompositionTests/CompositionTests.cs
index 28e0f00..a39740d 100644
--- a/tests/Kontext.CompositionTests/CompositionTests.cs
+++ b/tests/Kontext.CompositionTests/CompositionTests.cs
@@ -61,6 +61,7 @@ public void BuildTheContainer()
typeof(IClaudeLauncher),
typeof(IWorkspaceOpener),
typeof(IWorkspaceChanges),
+ typeof(IWorkspaceCommitter),
typeof(ClaudeEnvironmentScrubber),
typeof(TerminalZoom),
];
diff --git a/tests/Kontext.GitAutomation.IntegrationTests/WorkspaceCommitterIntegrationTests.cs b/tests/Kontext.GitAutomation.IntegrationTests/WorkspaceCommitterIntegrationTests.cs
new file mode 100644
index 0000000..5ffd96b
--- /dev/null
+++ b/tests/Kontext.GitAutomation.IntegrationTests/WorkspaceCommitterIntegrationTests.cs
@@ -0,0 +1,185 @@
+using Kontext.Domain;
+
+namespace Kontext.GitAutomation.IntegrationTests;
+
+[TestFixture]
+public class WorkspaceCommitterIntegrationTests
+{
+ private static readonly string[] CommittedFiles = ["README.md", "new.txt"];
+
+ private readonly GitProcess _git = new(new NoGitAuthentication());
+ private TempDirectory _temp = null!;
+ private WorkspaceChanges _changes = null!;
+ private WorkspaceCommitter _committer = null!;
+ private string _repo = null!;
+ private string _origin = null!;
+
+ [SetUp]
+ public async Task CreateRepositories()
+ {
+ _temp = new TempDirectory();
+ _changes = new WorkspaceChanges(_git);
+ _committer = new WorkspaceCommitter(_git);
+
+ _origin = _temp.Combine("origin.git");
+ Directory.CreateDirectory(_origin);
+ await _git.RunCheckedAsync(_origin, ["init", "--bare", "--initial-branch=main"]);
+
+ _repo = _temp.Combine("repo");
+ Directory.CreateDirectory(_repo);
+ await Git("init", "--initial-branch=main");
+ await Git("config", "user.email", "test@example.com");
+ await Git("config", "user.name", "Test");
+ await Git("config", "core.autocrlf", "false");
+ await Git("remote", "add", "origin", _origin);
+ Write("README.md", "one\n");
+ Write("keep.txt", "keep\n");
+ await Git("add", "--", ".");
+ await Git("commit", "-m", "initial");
+ await Git("push", "--set-upstream", "origin", "main");
+ await Git("checkout", "-b", "feature");
+ }
+
+ [TearDown]
+ public void DeleteRepositories() => _temp.Dispose();
+
+ private async Task Git(params string[] arguments) => await _git.RunCheckedAsync(_repo, arguments);
+
+ private async Task Output(params string[] arguments) => (await _git.RunCheckedAsync(_repo, arguments)).Output;
+
+ private async Task OriginHead(string branch) => (await _git.RunCheckedAsync(_origin, ["rev-parse", branch])).Output;
+
+ private void Write(string name, string content) => File.WriteAllText(System.IO.Path.Combine(_repo, name), content);
+
+ private async Task ChangeOf(string path)
+ {
+ var changes = await _changes.GetChangesAsync(_repo);
+ return changes.Staged.Concat(changes.Unstaged).First(change => change.Path == path);
+ }
+
+ private static string[] Lines(string text) => text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ [Test]
+ public async Task CommitsOnlyTheSelectedFiles()
+ {
+ Write("README.md", "one\ntwo\n");
+ Write("other.txt", "other\n");
+ Write("new.txt", "new\n");
+
+ await _committer.CommitAsync(_repo, [await ChangeOf("README.md"), await ChangeOf("new.txt")], "Add things");
+
+ Assert.That(await Output("log", "-1", "--format=%s"), Is.EqualTo("Add things"));
+ Assert.That(Lines(await Output("show", "--name-only", "--format=", "HEAD")), Is.EquivalentTo(CommittedFiles));
+ var remaining = await _changes.GetChangesAsync(_repo);
+ Assert.That(remaining.Unstaged.Select(change => change.Path).Single(), Is.EqualTo("other.txt"));
+ }
+
+ [Test]
+ public async Task LeavesOtherStagedFilesStagedAndOutOfTheCommit()
+ {
+ Write("README.md", "one\ntwo\n");
+ Write("keep.txt", "keep\nmore\n");
+ await Git("add", "--", "keep.txt");
+
+ await _committer.CommitAsync(_repo, [await ChangeOf("README.md")], "Only readme");
+
+ Assert.That(await Output("show", "--name-only", "--format=", "HEAD"), Is.EqualTo("README.md"));
+ Assert.That(await Output("diff", "--cached", "--name-only"), Is.EqualTo("keep.txt"));
+ }
+
+ [Test]
+ public async Task CommitsADeletedFile()
+ {
+ File.Delete(System.IO.Path.Combine(_repo, "keep.txt"));
+
+ await _committer.CommitAsync(_repo, [await ChangeOf("keep.txt")], "Remove keep");
+
+ Assert.That(await Output("show", "--name-status", "--format=", "HEAD"), Is.EqualTo("D\tkeep.txt"));
+ }
+
+ [Test]
+ public async Task CommitsARenameWithBothPaths()
+ {
+ await Git("mv", "keep.txt", "moved.txt");
+
+ await _committer.CommitAsync(_repo, [await ChangeOf("moved.txt")], "Move keep");
+
+ Assert.That(await Output("show", "--name-status", "--format=", "HEAD"), Does.StartWith("R100\tkeep.txt\tmoved.txt"));
+ Assert.That(await _changes.GetChangesAsync(_repo), Is.EqualTo(WorkingTreeChanges.Empty));
+ }
+
+ [Test]
+ public void RejectsNoSelectionAndAnEmptyMessage()
+ {
+ var change = new FileChange("README.md", null, FileChangeStatus.Modified, IsStaged: false, 1, 0, IsBinary: false);
+
+ Assert.That(() => _committer.CommitAsync(_repo, [], "msg"), Throws.TypeOf());
+ Assert.That(() => _committer.CommitAsync(_repo, [change], " "), Throws.InstanceOf());
+ }
+
+ [Test]
+ public async Task ReportsNothingToCommitWhenTheSelectionIsUnchanged()
+ {
+ var unchanged = new FileChange("README.md", null, FileChangeStatus.Modified, IsStaged: false, 1, 0, IsBinary: false);
+
+ Assert.That(() => _committer.CommitAsync(_repo, [unchanged], "Nothing"), Throws.TypeOf().With.Message.StartsWith("Nothing to commit"));
+ Assert.That(await Output("rev-list", "--count", "HEAD"), Is.EqualTo("1"));
+ }
+
+ [Test]
+ public async Task PushesTheBranchAndSetsTheUpstream()
+ {
+ Write("README.md", "one\ntwo\n");
+ await _committer.CommitAsync(_repo, [await ChangeOf("README.md")], "Change readme");
+ Assert.That(await _committer.CountUnpushedAsync(_repo), Is.EqualTo(1));
+
+ await _committer.PushAsync(_repo);
+
+ Assert.That(await OriginHead("feature"), Is.EqualTo(await Output("rev-parse", "feature")));
+ Assert.That(await Output("rev-parse", "--abbrev-ref", "feature@{upstream}"), Is.EqualTo("origin/feature"));
+ Assert.That(await _committer.CountUnpushedAsync(_repo), Is.Zero);
+ }
+
+ [Test]
+ public async Task CountsUnpushedCommitsOfABranchWithoutAnUpstream()
+ {
+ Assert.That(await _committer.CountUnpushedAsync(_repo), Is.Zero);
+
+ Write("README.md", "one\ntwo\n");
+ await _committer.CommitAsync(_repo, [await ChangeOf("README.md")], "One");
+ Write("README.md", "one\ntwo\nthree\n");
+ await _committer.CommitAsync(_repo, [await ChangeOf("README.md")], "Two");
+
+ Assert.That(await _committer.CountUnpushedAsync(_repo), Is.EqualTo(2));
+ }
+
+ [Test]
+ public async Task AFailedPushIsReportedAndLeavesTheCommitInPlace()
+ {
+ Write("README.md", "one\ntwo\n");
+ await _committer.CommitAsync(_repo, [await ChangeOf("README.md")], "Change readme");
+ await Git("remote", "set-url", "origin", _temp.Combine("missing.git"));
+
+ Assert.That(() => _committer.PushAsync(_repo), Throws.TypeOf().With.Message.StartsWith("Push failed"));
+ Assert.That(await _committer.CountUnpushedAsync(_repo), Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task ARejectedPushIsExplained()
+ {
+ var other = _temp.Combine("other");
+ await _git.RunCheckedAsync(_temp.Path, ["clone", _origin, "other"]);
+ await _git.RunCheckedAsync(other, ["config", "user.email", "o@example.com"]);
+ await _git.RunCheckedAsync(other, ["config", "user.name", "Other"]);
+ await _git.RunCheckedAsync(other, ["checkout", "-b", "feature"]);
+ File.WriteAllText(System.IO.Path.Combine(other, "x.txt"), "x\n");
+ await _git.RunCheckedAsync(other, ["add", "--", "x.txt"]);
+ await _git.RunCheckedAsync(other, ["commit", "-m", "remote work"]);
+ await _git.RunCheckedAsync(other, ["push", "origin", "feature"]);
+
+ Write("README.md", "one\ntwo\n");
+ await _committer.CommitAsync(_repo, [await ChangeOf("README.md")], "Local work");
+
+ Assert.That(() => _committer.PushAsync(_repo), Throws.TypeOf().With.Message.StartsWith("Push rejected"));
+ }
+}
diff --git a/tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs
index a29f378..291298c 100644
--- a/tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs
+++ b/tests/Kontext.Presentation.UnitTests/GitChangesViewModelTests.cs
@@ -11,6 +11,7 @@ public class GitChangesViewModelTests
private static readonly TimeSpan Interval = TimeSpan.FromSeconds(3);
private FakeWorkspaceChanges _fake = null!;
+ private FakeWorkspaceCommitter _committer = null!;
private FakeTimeProvider _clock = null!;
private GitChangesViewModel _vm = null!;
@@ -18,8 +19,9 @@ public class GitChangesViewModelTests
public void CreateViewModel()
{
_fake = new FakeWorkspaceChanges();
+ _committer = new FakeWorkspaceCommitter();
_clock = new FakeTimeProvider();
- _vm = new GitChangesViewModel(_fake, _clock, new GitChangesOptions(Interval));
+ _vm = new GitChangesViewModel(_fake, _committer, _clock, new GitChangesOptions(Interval));
}
[TearDown]
@@ -462,4 +464,217 @@ public async Task IgnoresAStaleResultFromThePreviousWorktree()
Assert.That(_vm.Rows.Select(row => row.Text), Does.Not.Contain("stale.cs"));
Assert.That(_vm.SelectedChange?.Path, Is.EqualTo("fresh.cs"));
}
+
+ private GitChangeRow FileRow(string path) => _vm.Rows.First(row => row.Change?.Path == path);
+
+ private async Task LoadTwoFiles()
+ {
+ _fake.Changes = Unstaged(Change("a.cs"), Change("b.cs"));
+ Activate();
+ await WaitForLoaded();
+ }
+
+ [Test]
+ public async Task CommitIsDisabledWithoutASelectionOrAMessage()
+ {
+ await LoadTwoFiles();
+ Assert.That(_vm.CommitCommand.CanExecute(null), Is.False);
+
+ FileRow("a.cs").IsChecked = true;
+ Assert.That(_vm.CommitCommand.CanExecute(null), Is.False, "no message yet");
+
+ _vm.CommitMessage = " ";
+ Assert.That(_vm.CommitCommand.CanExecute(null), Is.False, "blank message");
+
+ _vm.CommitMessage = "Fix a";
+ Assert.That(_vm.CommitCommand.CanExecute(null), Is.True);
+
+ FileRow("a.cs").IsChecked = false;
+ Assert.That(_vm.CommitCommand.CanExecute(null), Is.False, "selection removed");
+ }
+
+ [Test]
+ public async Task CommitsOnlyTheCheckedFilesAndResetsTheForm()
+ {
+ await LoadTwoFiles();
+ FileRow("b.cs").IsChecked = true;
+ _vm.CommitMessage = " Fix b ";
+ _committer.OnCommitted = _ => _fake.Changes = Unstaged(Change("a.cs"));
+
+ _vm.CommitCommand.Execute(null);
+ await WaitFor(() => _vm.StatusMessage is not null, "the commit to finish");
+
+ Assert.That(_committer.Commits, Has.Count.EqualTo(1));
+ Assert.That(_committer.Commits[0].Changes.Select(change => change.Path).Single(), Is.EqualTo("b.cs"));
+ Assert.That(_committer.Commits[0].Message, Is.EqualTo("Fix b"));
+ Assert.That(_committer.Commits[0].WorktreePath, Is.EqualTo(Worktree));
+ await WaitFor(() => _vm.Rows.All(row => row.Change?.Path != "b.cs"), "the list to refresh");
+ Assert.That(_vm.CommitMessage, Is.Empty);
+ Assert.That(_vm.CheckedCount, Is.Zero);
+ Assert.That(_vm.UnpushedCount, Is.EqualTo(1));
+ Assert.That(_vm.HasActionError, Is.False);
+ }
+
+ [Test]
+ public async Task AFailedCommitIsShownAndKeepsTheMessageAndSelection()
+ {
+ await LoadTwoFiles();
+ FileRow("a.cs").IsChecked = true;
+ _vm.CommitMessage = "Fix a";
+ _committer.CommitFailure = new InvalidOperationException("hook rejected the commit");
+
+ _vm.CommitCommand.Execute(null);
+ await WaitFor(() => _vm.HasActionError, "the error to show");
+
+ Assert.That(_vm.ActionError, Is.EqualTo("hook rejected the commit"));
+ Assert.That(_vm.CommitMessage, Is.EqualTo("Fix a"));
+ Assert.That(_vm.CheckedCount, Is.EqualTo(1));
+ Assert.That(FileRow("a.cs").IsChecked, Is.True);
+ Assert.That(_vm.IsBusy, Is.False);
+ Assert.That(_vm.CommitCommand.CanExecute(null), Is.True);
+ }
+
+ [Test]
+ public async Task PushIsDisabledUntilThereIsSomethingToPush()
+ {
+ await LoadTwoFiles();
+ Assert.That(_vm.PushCommand.CanExecute(null), Is.False);
+
+ _committer.Unpushed = 2;
+ _clock.Advance(Interval);
+ await WaitFor(() => _vm.UnpushedCount == 2, "the unpushed count");
+
+ Assert.That(_vm.PushCommand.CanExecute(null), Is.True);
+ }
+
+ [Test]
+ public async Task PushSendsTheWorktreeAndThenHasNothingLeftToPush()
+ {
+ _committer.Unpushed = 1;
+ await LoadTwoFiles();
+ await WaitFor(() => _vm.PushCommand.CanExecute(null), "push to be enabled");
+
+ _vm.PushCommand.Execute(null);
+ await WaitFor(() => _vm.StatusMessage is not null, "the push to finish");
+
+ Assert.That(_committer.Pushes.Single(), Is.EqualTo(Worktree));
+ await WaitFor(() => _vm.UnpushedCount == 0, "the unpushed count to refresh");
+ Assert.That(_vm.PushCommand.CanExecute(null), Is.False);
+ }
+
+ [Test]
+ public async Task AFailedPushIsShownAndStaysPushable()
+ {
+ _committer.Unpushed = 1;
+ _committer.PushFailure = new InvalidOperationException("Push rejected");
+ await LoadTwoFiles();
+ await WaitFor(() => _vm.PushCommand.CanExecute(null), "push to be enabled");
+
+ _vm.PushCommand.Execute(null);
+ await WaitFor(() => _vm.HasActionError, "the error to show");
+
+ Assert.That(_vm.ActionError, Is.EqualTo("Push rejected"));
+ Assert.That(_vm.UnpushedCount, Is.EqualTo(1));
+ Assert.That(_vm.PushCommand.CanExecute(null), Is.True);
+ }
+
+ [Test]
+ public async Task CommitAndPushAreDisabledWhileAnActionRuns()
+ {
+ _committer.Unpushed = 1;
+ await LoadTwoFiles();
+ FileRow("a.cs").IsChecked = true;
+ _vm.CommitMessage = "Fix a";
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _committer.Gate = gate;
+
+ _vm.CommitCommand.Execute(null);
+ await WaitFor(() => _vm.IsBusy, "the commit to start");
+
+ Assert.That(_vm.BusyText, Is.Not.Empty);
+ Assert.That(_vm.CommitCommand.CanExecute(null), Is.False);
+ Assert.That(_vm.PushCommand.CanExecute(null), Is.False);
+
+ gate.SetResult(true);
+ await WaitFor(() => !_vm.IsBusy, "the commit to finish");
+ }
+
+ [Test]
+ public async Task TheSelectionSurvivesAPollAndIsDroppedWhenTheFileDisappears()
+ {
+ await LoadTwoFiles();
+ FileRow("a.cs").IsChecked = true;
+ FileRow("b.cs").IsChecked = true;
+
+ _fake.Changes = Unstaged(Change("a.cs"), Change("b.cs", additions: 5));
+ _clock.Advance(Interval);
+ await WaitFor(() => _vm.Rows.Any(row => row.Change?.Additions == 5), "the changed list");
+ Assert.That(FileRow("a.cs").IsChecked, Is.True);
+ Assert.That(FileRow("b.cs").IsChecked, Is.True);
+
+ _fake.Changes = Unstaged(Change("a.cs"));
+ _clock.Advance(Interval);
+ await WaitFor(() => _vm.CheckedCount == 1, "b.cs to be dropped from the selection");
+ }
+
+ [Test]
+ public async Task TheAllBoxChecksAndUnchecksEveryFile()
+ {
+ await LoadTwoFiles();
+ Assert.That(_vm.AllChecked, Is.False);
+
+ _vm.AllChecked = true;
+
+ Assert.That(_vm.CheckedCount, Is.EqualTo(2));
+ Assert.That(_vm.Rows.Where(row => !row.IsHeader).All(row => row.IsChecked), Is.True);
+ Assert.That(_vm.AllChecked, Is.True);
+
+ _vm.AllChecked = false;
+
+ Assert.That(_vm.CheckedCount, Is.Zero);
+ Assert.That(_vm.Rows.Where(row => !row.IsHeader).Any(row => row.IsChecked), Is.False);
+ }
+
+ [Test]
+ public async Task TheAllBoxIsUndeterminedWhenOnlySomeFilesAreChecked()
+ {
+ await LoadTwoFiles();
+ var changed = new List();
+ _vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ FileRow("a.cs").IsChecked = true;
+
+ Assert.That(_vm.AllChecked, Is.Null);
+ Assert.That(changed, Does.Contain(nameof(GitChangesViewModel.AllChecked)));
+
+ FileRow("b.cs").IsChecked = true;
+
+ Assert.That(_vm.AllChecked, Is.True);
+ }
+
+ [Test]
+ public async Task TheViewWritingTheUndeterminedStateBackKeepsTheSelection()
+ {
+ await LoadTwoFiles();
+ FileRow("a.cs").IsChecked = true;
+
+ _vm.AllChecked = null;
+
+ Assert.That(_vm.CheckedCount, Is.EqualTo(1));
+ Assert.That(FileRow("a.cs").IsChecked, Is.True);
+ }
+
+ [Test]
+ public async Task AFileListedAsStagedAndChangedIsCheckedInBothRows()
+ {
+ _fake.Changes = Tree([Change("p.cs", staged: true)], [Change("p.cs")]);
+ Activate();
+ await WaitForLoaded();
+
+ var rows = _vm.Rows.Where(row => row.Change?.Path == "p.cs").ToList();
+ rows[0].IsChecked = true;
+
+ Assert.That(rows[1].IsChecked, Is.True);
+ Assert.That(_vm.CheckedCount, Is.EqualTo(1));
+ }
}
diff --git a/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs
index 0256839..f8a89c6 100644
--- a/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs
+++ b/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs
@@ -33,7 +33,7 @@ public void CreateDetail()
_clock = new FakeTimeProvider();
_autoStart = new SelectionAutoStart(_coordinator, _tracker, new InMemoryIssueSessionRegistry(), _clock, new AutoStartOptions(TimeSpan.FromSeconds(1)));
_workspaceChanges = new FakeWorkspaceChanges();
- var gitChanges = new GitChangesViewModel(_workspaceChanges, _clock, new GitChangesOptions(TimeSpan.FromSeconds(3)));
+ var gitChanges = new GitChangesViewModel(_workspaceChanges, new FakeWorkspaceCommitter(), _clock, new GitChangesOptions(TimeSpan.FromSeconds(3)));
_detail = new IssueDetailViewModel(_coordinator, _clearer, _confirmation, _opener, _registry, _autoStart, gitChanges);
}
diff --git a/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs
index f39f8d9..1e423c9 100644
--- a/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs
+++ b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs
@@ -40,7 +40,7 @@ protected void Build(string? savedToken, string? savedEnterpriseToken = null)
DeviceLogin = new FakeGitHubDeviceLogin();
_autoStart = new SelectionAutoStart(coordinator, tracker, new InMemoryIssueSessionRegistry(), TimeProvider.System, AutoStartOptions.Default);
Sidebar = new IssueSidebarViewModel(GitHub, tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(GitHub), new InMemoryHiddenRepositoryStore(), new InMemoryCollapsedRepositoryStore(), new FakeSessionStore(), TimeProvider.System);
- _detail = new IssueDetailViewModel(coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart, new GitChangesViewModel(new FakeWorkspaceChanges(), TimeProvider.System, GitChangesOptions.Default));
+ _detail = new IssueDetailViewModel(coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart, new GitChangesViewModel(new FakeWorkspaceChanges(), new FakeWorkspaceCommitter(), TimeProvider.System, GitChangesOptions.Default));
Shell = new ShellViewModel(GitHub, DeviceLogin, Tokens, EnterprisePrompt, new WorkspaceViewModel(Sidebar, _detail, _autoStart));
}
}
diff --git a/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs
index 2f2a6fa..137683a 100644
--- a/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs
+++ b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs
@@ -33,7 +33,7 @@ public async Task CreateWorkspace()
_sidebar = new IssueSidebarViewModel(gitHub, _tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(gitHub), new InMemoryHiddenRepositoryStore(), new InMemoryCollapsedRepositoryStore(), new FakeSessionStore(), _clock);
_registry = new InMemoryIssueSessionRegistry();
- _detail = new IssueDetailViewModel(_coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), _registry, _autoStart, new GitChangesViewModel(new FakeWorkspaceChanges(), _clock, GitChangesOptions.Default));
+ _detail = new IssueDetailViewModel(_coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), _registry, _autoStart, new GitChangesViewModel(new FakeWorkspaceChanges(), new FakeWorkspaceCommitter(), _clock, GitChangesOptions.Default));
_workspace = new WorkspaceViewModel(_sidebar, _detail, _autoStart);
await _sidebar.RefreshCommand.ExecuteAsync(null);
diff --git a/tests/Kontext.TestSupport/WorkspaceCommitterFakes.cs b/tests/Kontext.TestSupport/WorkspaceCommitterFakes.cs
new file mode 100644
index 0000000..b76a0be
--- /dev/null
+++ b/tests/Kontext.TestSupport/WorkspaceCommitterFakes.cs
@@ -0,0 +1,91 @@
+using Kontext.Contracts;
+using Kontext.Domain;
+
+namespace Kontext.TestSupport;
+
+public sealed record CommitRequest(string WorktreePath, IReadOnlyList Changes, string Message);
+
+public sealed class FakeWorkspaceCommitter : IWorkspaceCommitter
+{
+ private readonly object _lock = new();
+ private readonly List _commits = [];
+ private readonly List _pushes = [];
+
+ public int Unpushed { get; set; }
+
+ public Exception? CommitFailure { get; set; }
+
+ public Exception? PushFailure { get; set; }
+
+ /// When set, a commit or push waits for the gate after recording the call.
+ public TaskCompletionSource? Gate { get; set; }
+
+ /// Runs after a successful commit, so a test can change what the fake workspace reports.
+ public Action? OnCommitted { get; set; }
+
+ public IReadOnlyList Commits
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return [.. _commits];
+ }
+ }
+ }
+
+ public IReadOnlyList Pushes
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return [.. _pushes];
+ }
+ }
+ }
+
+ public async Task CommitAsync(string worktreePath, IReadOnlyList changes, string message, CancellationToken cancellationToken = default)
+ {
+ var request = new CommitRequest(worktreePath, changes, message);
+ lock (_lock)
+ {
+ _commits.Add(request);
+ }
+
+ if (Gate is { } gate)
+ {
+ await gate.Task;
+ }
+
+ if (CommitFailure is { } failure)
+ {
+ throw failure;
+ }
+
+ Unpushed++;
+ OnCommitted?.Invoke(request);
+ }
+
+ public async Task PushAsync(string worktreePath, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ _pushes.Add(worktreePath);
+ }
+
+ if (Gate is { } gate)
+ {
+ await gate.Task;
+ }
+
+ if (PushFailure is { } failure)
+ {
+ throw failure;
+ }
+
+ Unpushed = 0;
+ }
+
+ public Task CountUnpushedAsync(string worktreePath, CancellationToken cancellationToken = default) => Task.FromResult(Unpushed);
+}
diff --git a/tests/Kontext.UiTests/Application/FakeGitHub.cs b/tests/Kontext.UiTests/Application/FakeGitHub.cs
index d45e80c..082bd5e 100644
--- a/tests/Kontext.UiTests/Application/FakeGitHub.cs
+++ b/tests/Kontext.UiTests/Application/FakeGitHub.cs
@@ -10,6 +10,9 @@ public static class FakeGitHub
public const string Memento = "acme/memento";
public const string CreatingGalleries = "Creating Galleries";
+ /// The one fake issue that has a worktree, so its git tab is available.
+ public const string DrawTheCoastline = "Draw the coastline";
+
public const string AddNotifications = "Add notifications";
public const string BringUnderUiTest = "Bring project under UI test";
diff --git a/tests/Kontext.UiTests/Application/GitTabPage.cs b/tests/Kontext.UiTests/Application/GitTabPage.cs
new file mode 100644
index 0000000..9b05729
--- /dev/null
+++ b/tests/Kontext.UiTests/Application/GitTabPage.cs
@@ -0,0 +1,35 @@
+using WinUia;
+using WinUia.Patterns;
+
+namespace Kontext.UiTests.Application;
+
+/// The git tab of the issue pane: changed files with checkboxes, the commit message, and Commit and Push.
+public sealed class GitTabPage(Element window)
+{
+ public void Open() => window.FindByAutomationId("GitTab").Click();
+
+ public Element File(string path) => window.Find(e => e.ControlType == ControlType.CheckBox && e.Name == path);
+
+ public bool HasFile(string path) =>
+ window.TryFind(e => e.ControlType == ControlType.CheckBox && e.Name == path, timeout: TimeSpan.Zero) is not null;
+
+ public bool IsChecked(string path) => File(path).TogglePattern.State == ToggleState.On;
+
+ public void Check(string path)
+ {
+ if (!IsChecked(path))
+ {
+ File(path).Toggle();
+ }
+ }
+
+ public void TypeMessage(string message) => window.FindByAutomationId("GitCommitMessage").SetValue(message);
+
+ public Element CommitButton => window.FindByAutomationId("GitCommitButton");
+
+ public Element PushButton => window.FindByAutomationId("GitPushButton");
+
+ public string? Error => window.TryFindByAutomationId("GitActionError", TimeSpan.Zero)?.Name;
+
+ public string? Status => window.TryFindByAutomationId("GitStatusMessage", TimeSpan.Zero)?.Name;
+}
diff --git a/tests/Kontext.UiTests/Application/KontextApp.cs b/tests/Kontext.UiTests/Application/KontextApp.cs
index c532ec6..b2e9968 100644
--- a/tests/Kontext.UiTests/Application/KontextApp.cs
+++ b/tests/Kontext.UiTests/Application/KontextApp.cs
@@ -19,6 +19,8 @@ public sealed class KontextApp : App
public IssueDetailPage IssueDetail => new(MainWindow);
+ public GitTabPage GitTab => new(MainWindow);
+
protected override string ExecutablePath => BuiltExecutable;
protected override AppLaunchOptions DefaultOptions => new() { ShowPointer = true };
diff --git a/tests/Kontext.UiTests/Tests/GitTabTests.cs b/tests/Kontext.UiTests/Tests/GitTabTests.cs
new file mode 100644
index 0000000..c8ed4a6
--- /dev/null
+++ b/tests/Kontext.UiTests/Tests/GitTabTests.cs
@@ -0,0 +1,88 @@
+using Kontext.UiTests.Application;
+using static Kontext.UiTests.Application.FakeGitHub;
+
+namespace Kontext.UiTests.Tests;
+
+///
+/// The file list is virtualised and the CI window is small, so each test only relies on the first file row (Map),
+/// plus Legend once Map has been committed and Legend has moved up to take its place.
+///
+public sealed class GitTabTests : KontextFixture
+{
+ private const string Map = "src/Map.cs";
+ private const string Legend = "src/Legend.cs";
+
+ [SetUp]
+ public void OpenGitTab()
+ {
+ App.Sidebar.Issue(DrawTheCoastline).Click();
+ Eventually(() => App.IssueDetail.Title == DrawTheCoastline, "the issue opens");
+ App.GitTab.Open();
+ Eventually(() => App.GitTab.HasFile(Map), "the changed files are listed");
+ }
+
+ [Test]
+ public void ListsTheChangedFilesAndDisablesCommitAndPushUntilApplicable()
+ {
+ Assert.That(App.GitTab.CommitButton.IsEnabled, Is.False, "nothing selected, no message");
+ Assert.That(App.GitTab.PushButton.IsEnabled, Is.False, "nothing to push");
+
+ App.GitTab.Check(Map);
+ Assert.That(App.GitTab.CommitButton.IsEnabled, Is.False, "no message yet");
+
+ App.GitTab.TypeMessage("Draw the map");
+ Eventually(() => App.GitTab.CommitButton.IsEnabled, "commit is enabled with a selection and a message");
+ }
+
+ [Test]
+ public void CommitsTheSelectedFilesAndThenPushesThem()
+ {
+ App.GitTab.Check(Map);
+ App.GitTab.TypeMessage("Draw the map");
+ Eventually(() => App.GitTab.CommitButton.IsEnabled, "commit is enabled");
+
+ App.GitTab.CommitButton.Click();
+
+ Eventually(() => !App.GitTab.HasFile(Map), "the committed file leaves the list");
+ Eventually(() => App.GitTab.HasFile(Legend), "unselected files stay");
+ Eventually(() => App.GitTab.Status == "Committed 1 file.", "the commit is confirmed");
+ Eventually(() => App.GitTab.PushButton.IsEnabled, "push is enabled after a commit");
+
+ App.GitTab.PushButton.Click();
+
+ Eventually(() => App.GitTab.Status == "Pushed to origin.", "the push is confirmed");
+ Eventually(() => !App.GitTab.PushButton.IsEnabled, "nothing is left to push");
+ }
+
+ [Test]
+ public void ShowsAFailedCommitAndKeepsTheSelection()
+ {
+ App.GitTab.Check(Map);
+ App.GitTab.TypeMessage("This will fail");
+ Eventually(() => App.GitTab.CommitButton.IsEnabled, "commit is enabled");
+
+ App.GitTab.CommitButton.Click();
+
+ Eventually(() => App.GitTab.Error is { Length: > 0 }, "the error is shown");
+ Assert.That(App.GitTab.Error, Does.Contain("Commit failed"));
+ Assert.That(App.GitTab.HasFile(Map), Is.True);
+ Assert.That(App.GitTab.IsChecked(Map), Is.True);
+ Assert.That(App.GitTab.CommitButton.IsEnabled, Is.True, "the user can retry");
+ }
+
+ [Test]
+ public void ShowsARejectedPushAndStaysPushable()
+ {
+ App.GitTab.Check(Map);
+ App.GitTab.TypeMessage("Draw the map (reject)");
+ Eventually(() => App.GitTab.CommitButton.IsEnabled, "commit is enabled");
+ App.GitTab.CommitButton.Click();
+ Eventually(() => App.GitTab.PushButton.IsEnabled, "push is enabled after a commit");
+
+ App.GitTab.PushButton.Click();
+
+ Eventually(() => App.GitTab.Error is { Length: > 0 }, "the error is shown");
+ Assert.That(App.GitTab.Error, Does.Contain("Push rejected"));
+ Assert.That(App.GitTab.PushButton.IsEnabled, Is.True, "the commit is still unpushed");
+ }
+}