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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/Kontext.Contracts/IWorkspaceCommitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Kontext.Domain;

namespace Kontext.Contracts;

/// <summary>Commits and pushes the changes of one worktree.</summary>
public interface IWorkspaceCommitter
{
/// <summary>Commits exactly the given changes with the message; other staged or unstaged files are left alone.</summary>
Task CommitAsync(string worktreePath, IReadOnlyList<FileChange> changes, string message, CancellationToken cancellationToken = default);

/// <summary>Pushes the checked-out branch to <c>origin</c>, setting it as the upstream.</summary>
Task PushAsync(string worktreePath, CancellationToken cancellationToken = default);

/// <summary>The number of commits on the checked-out branch that no remote has yet.</summary>
Task<int> CountUnpushedAsync(string worktreePath, CancellationToken cancellationToken = default);
}
1 change: 1 addition & 0 deletions src/Kontext.GitAutomation/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public static IServiceCollection AddKontextGitAutomation(this IServiceCollection
services.AddSingleton<IGitProcess, GitProcess>();
services.AddSingleton<IBranchNamer, BranchNamer>();
services.AddSingleton<IWorktreePathProvider, WorktreePathProvider>();
services.AddSingleton<IWorkspaceCommitter, WorkspaceCommitter>();
services.AddSingleton<IGitWorktreeService, GitWorktreeService>();
services.AddSingleton<IWorkspaceChanges, WorkspaceChanges>();
services.AddSingleton<IGitRepositoryInspector, GitRepositoryInspector>();
Expand Down
80 changes: 80 additions & 0 deletions src/Kontext.GitAutomation/WorkspaceCommitter.cs
Original file line number Diff line number Diff line change
@@ -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<FileChange> 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<int> 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);
}
43 changes: 42 additions & 1 deletion src/Kontext.Presentation/GitChangeRow.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
using System.ComponentModel;
using System.Globalization;
using Kontext.Domain;

namespace Kontext.Presentation;

/// <summary>One line of the change list: either a group header ("Staged (2)") or a changed file.</summary>
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;

/// <summary>Whether the file is included in the next commit. Called by the list when the user toggles it.</summary>
public bool IsChecked
{
get => _isChecked;
set
{
if (_isChecked == value)
{
return;
}

_isChecked = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked)));
Toggled?.Invoke(this, value);
}
}

/// <summary>Starts the row checked without reporting a toggle.</summary>
public bool InitiallyChecked
{
init => _isChecked = value;
}

/// <summary>Told when the user checks or unchecks the row.</summary>
public Action<GitChangeRow, bool>? Toggled { get; init; }

public string StatusLetter => Change?.Status switch
{
FileChangeStatus.Modified => "M",
Expand All @@ -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;

/// <summary>Sets the checked state from the view model, so the box follows without reporting a toggle.</summary>
internal void SetCheckedFromModel(bool value)
{
if (_isChecked != value)
{
_isChecked = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked)));
}
}
}
Loading