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}"> + + + + + + + + + + + + + + +