diff --git a/OneWare.slnx b/OneWare.slnx
index 55c1bf9a0..82b230d12 100644
--- a/OneWare.slnx
+++ b/OneWare.slnx
@@ -134,6 +134,7 @@
+
diff --git a/docs/SourceControl.md b/docs/SourceControl.md
new file mode 100644
index 000000000..c28cbb587
--- /dev/null
+++ b/docs/SourceControl.md
@@ -0,0 +1,38 @@
+# Git support
+
+## Working with changes
+
+- **Changes** compares the working tree with the index (staging area), including new files.
+- **Staged Changes** compares the index with HEAD, including the first commit in a new repository.
+- A file with both staged and unstaged edits appears in both lists; each opens its own comparison tab.
+- **Commit Staged** commits only the index. **Commit All** stages and commits non-ignored changes.
+- Unresolved conflicts must be resolved and explicitly staged before committing. Commit All does not silently resolve them.
+- **Undo Changes** asks for confirmation and restores from the index, keeping staged edits.
+- **Undo All Changes** confirms a reset of staged and unstaged tracked changes to HEAD. Untracked files are kept; if an untracked file would be overwritten, the reset is refused.
+- Deleting an individual untracked file requires confirmation and is permanent.
+- **Open File (Head)** opens a separate read-only temporary snapshot, not the working editor.
+
+## Branches and remotes
+
+- Checking out remote branches preserves full names such as `feature/team/task` and sets tracking. An unrelated local branch with the same name is not silently reused.
+- Push can publish a branch to a selected remote. Upstream configuration is saved only after a successful push.
+- Remote deletion requires explicit confirmation; failed deletion keeps the local tracking reference.
+- Sync holds one operation lock across pull and push. A failed pull or merge conflict prevents the subsequent push.
+- Fetch and local polling use independent timers. Worktree scanning runs off the UI thread.
+- Background fetch does not open login dialogs or permanently disable itself after a network failure. Manual fetch can request authentication.
+- Clone checks its destination and offers to open the result as a folder project. Cancellation leaves downloaded files in place.
+
+Authentication still uses the existing credential store and login providers. This change does not add stash, rebase, history browsing, or new authentication providers.
+
+## Validation
+
+Automated regression tests are in [GitOperationsTests.cs](../tests/OneWare.SourceControl.UnitTests/GitOperationsTests.cs). They use temporary repositories and local bare remotes, without production credentials or remote writes.
+
+Manual smoke checks for a running desktop build:
+
+1. Stage a file, edit it again, and compare each list. Commit Staged from both the menu and button must leave the second edit uncommitted.
+2. Cancel discard and identity dialogs; verify files, index and commit message remain unchanged and commands become usable again.
+3. Enable both timers; verify local changes refresh while remote ahead/behind counts also update.
+4. Change the active project during a slow fetch; verify the operation finishes on its original repository and the new project's status is shown afterward.
+5. Clone into a chosen parent directory and open the result. Check cancellation and a non-empty destination as well.
+6. Publish, pull and push with a private repository using interactive authentication. Check rejected pushes and login cancellation.
\ No newline at end of file
diff --git a/src/OneWare.SourceControl/Converters/ChangeStatusBrushConverter.cs b/src/OneWare.SourceControl/Converters/ChangeStatusBrushConverter.cs
index 2ec7846d0..30a483e4e 100644
--- a/src/OneWare.SourceControl/Converters/ChangeStatusBrushConverter.cs
+++ b/src/OneWare.SourceControl/Converters/ChangeStatusBrushConverter.cs
@@ -15,14 +15,14 @@ public class ChangeStatusBrushConverter : IValueConverter
return status switch
{
FileStatus.Unaltered => Brushes.Transparent,
- FileStatus.Conflicted => Brushes.Purple,
- FileStatus.DeletedFromIndex => Brushes.Red,
- FileStatus.DeletedFromWorkdir => Brushes.Red,
- FileStatus.ModifiedInIndex => (IBrush?)new BrushConverter().ConvertFrom("#FFC107"),
- FileStatus.ModifiedInWorkdir => (IBrush?)new BrushConverter().ConvertFrom("#FFC107"),
- FileStatus.NewInIndex => Application.Current?.FindResource("GreenAccent"),
- FileStatus.NewInWorkdir => Application.Current?.FindResource("GreenAccent"),
- _ => Application.Current?.FindResource("ForegroundColor")
+ _ when status.HasFlag(FileStatus.Conflicted) => Brushes.Purple,
+ _ when (status & (FileStatus.DeletedFromIndex | FileStatus.DeletedFromWorkdir)) != 0 => Brushes.Red,
+ _ when (status & (FileStatus.ModifiedInIndex | FileStatus.ModifiedInWorkdir |
+ FileStatus.RenamedInIndex | FileStatus.RenamedInWorkdir |
+ FileStatus.TypeChangeInIndex | FileStatus.TypeChangeInWorkdir)) != 0 => Brushes.Goldenrod,
+ _ when (status & (FileStatus.NewInIndex | FileStatus.NewInWorkdir)) != 0 =>
+ Application.Current?.FindResource("GreenAccent") ?? Brushes.Green,
+ _ => Application.Current?.FindResource("ThemeForegroundBrush")
};
return null;
}
diff --git a/src/OneWare.SourceControl/Converters/ChangeStatusCharConverter.cs b/src/OneWare.SourceControl/Converters/ChangeStatusCharConverter.cs
index bf5bce296..4e3eef25c 100644
--- a/src/OneWare.SourceControl/Converters/ChangeStatusCharConverter.cs
+++ b/src/OneWare.SourceControl/Converters/ChangeStatusCharConverter.cs
@@ -11,9 +11,13 @@ public class ChangeStatusCharConverter : IValueConverter
if (value is FileStatus status)
return status switch
{
- FileStatus.NewInIndex => "+",
- FileStatus.NewInWorkdir => "+",
- _ => status.ToString()[0] + ""
+ _ when status.HasFlag(FileStatus.Conflicted) => "U",
+ _ when (status & (FileStatus.DeletedFromIndex | FileStatus.DeletedFromWorkdir)) != 0 => "D",
+ _ when (status & (FileStatus.RenamedInIndex | FileStatus.RenamedInWorkdir)) != 0 => "R",
+ _ when (status & (FileStatus.ModifiedInIndex | FileStatus.ModifiedInWorkdir)) != 0 => "M",
+ _ when (status & (FileStatus.TypeChangeInIndex | FileStatus.TypeChangeInWorkdir)) != 0 => "T",
+ _ when (status & (FileStatus.NewInIndex | FileStatus.NewInWorkdir)) != 0 => "+",
+ _ => ""
};
return null;
}
diff --git a/src/OneWare.SourceControl/GitOperations.cs b/src/OneWare.SourceControl/GitOperations.cs
new file mode 100644
index 000000000..ea4b95b81
--- /dev/null
+++ b/src/OneWare.SourceControl/GitOperations.cs
@@ -0,0 +1,133 @@
+using LibGit2Sharp;
+
+namespace OneWare.SourceControl;
+
+/// Git operations shared by the UI and repository-level regression tests.
+public static class GitOperations
+{
+ public static string GetCloneDirectoryName(string url)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(url);
+ var path = url.Trim().TrimEnd('/', '\\');
+ if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
+ path = uri.IsFile ? uri.LocalPath : uri.AbsolutePath.TrimEnd('/');
+ var name = path[(path.LastIndexOfAny(new[] { '/', '\\', ':' }) + 1)..];
+ if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) name = name[..^4];
+ if (string.IsNullOrWhiteSpace(name) || name is "." or ".." || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
+ throw new ArgumentException("The repository URL does not contain a valid folder name.", nameof(url));
+ return name;
+ }
+
+ public static string GetRelativePath(Repository repository, string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ var root = repository.Info.WorkingDirectory;
+ var relative = Path.GetRelativePath(root, Path.GetFullPath(path, root)).Replace('\\', '/');
+ if (relative == ".." || relative.StartsWith("../", StringComparison.Ordinal) || Path.IsPathRooted(relative))
+ throw new ArgumentException("The file is outside this repository.", nameof(path));
+ return relative;
+ }
+
+ public static string GetRemoteBranchName(Branch branch)
+ {
+ if (!branch.IsRemote) throw new ArgumentException("Expected a remote branch.", nameof(branch));
+ var prefix = $"refs/remotes/{branch.RemoteName}/";
+ if (!branch.CanonicalName.StartsWith(prefix, StringComparison.Ordinal))
+ throw new ArgumentException("Cannot determine the remote branch name.", nameof(branch));
+ return branch.CanonicalName[prefix.Length..];
+ }
+
+ public static Branch CheckoutBranch(Repository repository, Branch branch)
+ {
+ if (branch.IsRemote)
+ {
+ var name = GetRemoteBranchName(branch);
+ var local = repository.Branches[name];
+ if (local == null)
+ {
+ local = repository.CreateBranch(name, branch.Tip);
+ local = repository.Branches.Update(local, b => b.TrackedBranch = branch.CanonicalName);
+ }
+ else if (local.TrackedBranch?.CanonicalName != branch.CanonicalName)
+ {
+ throw new InvalidOperationException($"Local branch '{name}' already exists and does not track '{branch.FriendlyName}'.");
+ }
+ branch = local;
+ }
+ return Commands.Checkout(repository, branch);
+ }
+
+ public static Commit Commit(Repository repository, string message, Signature signature, bool stagedOnly)
+ {
+ if (string.IsNullOrWhiteSpace(message))
+ throw new ArgumentException("Enter a commit message before committing.", nameof(message));
+ // Do not let 'Commit All' silently mark unresolved conflicts as resolved.
+ if (repository.Index.Conflicts.Any())
+ throw new InvalidOperationException("Resolve and stage merge conflicts before committing.");
+ if (!stagedOnly) Commands.Stage(repository, "*");
+ return repository.Commit(message, signature, signature);
+ }
+
+ public static Branch PublishBranch(Repository repository, string remoteName, PushOptions options)
+ {
+ if (repository.Info.IsHeadDetached || repository.Head.Tip == null)
+ throw new InvalidOperationException("Check out a branch with at least one commit before publishing.");
+ var head = repository.Head;
+ repository.Network.Push(repository.Network.Remotes[remoteName],
+ $"{head.CanonicalName}:{head.CanonicalName}", options);
+ // Failed pushes must not leave behind upstream configuration.
+ return repository.Branches.Update(head, b => b.Remote = remoteName, b => b.UpstreamBranch = head.CanonicalName);
+ }
+
+ public static void DeleteRemoteBranch(Repository repository, Branch branch, PushOptions options)
+ {
+ var name = GetRemoteBranchName(branch);
+ repository.Network.Push(repository.Network.Remotes[branch.RemoteName], $":refs/heads/{name}", options);
+ // libgit2 may already have removed the local tracking ref after the push.
+ if (repository.Branches[branch.CanonicalName] is { } remaining) repository.Branches.Remove(remaining);
+ }
+
+ public static void DiscardWorkingTreeFile(Repository repository, string path)
+ {
+ path = GetRelativePath(repository, path);
+ if (repository.Index[path] == null)
+ throw new InvalidOperationException("This file is not in the index. Delete untracked files separately.");
+ // Restore from the index, NOT HEAD: preserve any already staged changes.
+ var tree = repository.Index.WriteToTree();
+ repository.Checkout(tree, new[] { path }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
+ }
+
+ public static void ResetTrackedChanges(Repository repository, ResetMode mode)
+ {
+ if (mode == ResetMode.Hard)
+ {
+ var head = repository.Head.Tip ?? throw new InvalidOperationException("There is no commit to reset to.");
+ var status = repository.RetrieveStatus(new StatusOptions { RecurseUntrackedDirs = true });
+ foreach (var entry in status.Where(x => x.State.HasFlag(FileStatus.NewInWorkdir)))
+ {
+ // A hard reset can overwrite an untracked file if it obstructs a HEAD entry.
+ // Keep the UI's promise to preserve untracked files, even after a staged deletion.
+ var path = entry.FilePath.TrimEnd('/');
+ if (head[path] != null)
+ throw new InvalidOperationException($"Move untracked file '{path}' before resetting; it would be overwritten.");
+ while (path.Contains('/'))
+ {
+ path = path[..path.LastIndexOf('/')];
+ if (head[path]?.TargetType == TreeEntryTargetType.Blob)
+ throw new InvalidOperationException($"Move untracked file '{entry.FilePath}' before resetting; it would be overwritten.");
+ }
+ }
+ }
+ repository.Reset(mode);
+ }
+
+ public static Patch GetPatch(Repository repository, string path, int contextLines, bool staged)
+ {
+ var paths = new[] { GetRelativePath(repository, path) };
+ var options = new CompareOptions { ContextLines = contextLines };
+ return staged
+ ? repository.Diff.Compare(repository.Head.Tip?.Tree, DiffTargets.Index, paths,
+ new ExplicitPathsOptions(), options)
+ : repository.Diff.Compare(paths, true, new ExplicitPathsOptions(), options);
+ }
+}
\ No newline at end of file
diff --git a/src/OneWare.SourceControl/Models/GitRepositoryModel.cs b/src/OneWare.SourceControl/Models/GitRepositoryModel.cs
index 302079e16..ea6a7b09a 100644
--- a/src/OneWare.SourceControl/Models/GitRepositoryModel.cs
+++ b/src/OneWare.SourceControl/Models/GitRepositoryModel.cs
@@ -13,7 +13,7 @@
namespace OneWare.SourceControl.Models;
-public class GitRepositoryModel : ObservableObject
+public class GitRepositoryModel : ObservableObject, IDisposable
{
private Branch? _headBranch;
@@ -27,6 +27,7 @@ public GitRepositoryModel(IProjectRoot project, Repository repository)
{
Project = project;
Repository = repository;
+ WorkingPath = repository.Info.WorkingDirectory;
}
public IProjectRoot Project { get; private set; }
@@ -64,23 +65,28 @@ public int PushCommits
public ObservableCollection AvailableBranchesMenu { get; } = new();
- public void Refresh(SourceControlViewModel sourceControlViewModel)
+ public async Task RefreshAsync(SourceControlViewModel sourceControlViewModel)
{
var changes = new List();
var stagedChanges = new List();
var mergeChanges = new List();
- var projectExplorerService = ContainerLocator.Container.Resolve();
-
try
{
- HeadBranch = Repository.Head;
+ // Scan the worktree off the UI thread; publish observable state only after it completes.
+ var snapshot = await Task.Run(() => (
+ Head: Repository.Head,
+ Branches: Repository.Branches.ToArray(),
+ Status: Repository.RetrieveStatus(new StatusOptions()).ToArray(),
+ Behind: Repository.Head.TrackingDetails.BehindBy ?? 0,
+ Ahead: Repository.Head.TrackingDetails.AheadBy ?? 0));
+ HeadBranch = snapshot.Head;
var branchesMenu = new List();
- foreach (var branch in Repository.Branches)
+ foreach (var branch in snapshot.Branches)
{
- var menuItem = new MenuItemModel("BranchName")
+ var menuItem = new MenuItemModel(branch.CanonicalName)
{
Header = branch.FriendlyName,
Command = new RelayCommand(() => sourceControlViewModel.ChangeBranch(branch))
@@ -117,11 +123,11 @@ public void Refresh(SourceControlViewModel sourceControlViewModel)
},
(a, b) =>
{
- if (a.Name == "New Branch...") return -1;
- if (b.Name == "New Branch...") return 1;
+ if (a.Name == "NewBranch") return 1;
+ if (b.Name == "NewBranch") return -1;
- var aTracking = a.Name.StartsWith("origin/");
- var bTracking = b.Name.StartsWith("origin/");
+ var aTracking = a.Name.StartsWith("refs/remotes/", StringComparison.Ordinal);
+ var bTracking = b.Name.StartsWith("refs/remotes/", StringComparison.Ordinal);
return aTracking switch
{
@@ -131,7 +137,7 @@ public void Refresh(SourceControlViewModel sourceControlViewModel)
};
});
- foreach (var item in Repository.RetrieveStatus(new StatusOptions()))
+ foreach (var item in snapshot.Status)
{
var fullPath = Path.Combine(Repository.Info.WorkingDirectory, item.FilePath);
@@ -140,6 +146,12 @@ public void Refresh(SourceControlViewModel sourceControlViewModel)
FileIcon = sourceControlViewModel.FileIconService.GetFileIconModel(Path.GetExtension(fullPath))
};
+ if (item.State.HasFlag(FileStatus.Conflicted))
+ {
+ mergeChanges.Add(sModel);
+ continue;
+ }
+
if (item.State.HasFlag(FileStatus.TypeChangeInIndex) ||
item.State.HasFlag(FileStatus.RenamedInIndex) ||
item.State.HasFlag(FileStatus.DeletedFromIndex) ||
@@ -154,15 +166,15 @@ public void Refresh(SourceControlViewModel sourceControlViewModel)
item.State.HasFlag(FileStatus.ModifiedInWorkdir))
changes.Add(sModel);
- if (item.State.HasFlag(FileStatus.Conflicted)) mergeChanges.Add(sModel);
}
- PullCommits = Repository?.Head.TrackingDetails.BehindBy ?? 0;
- PushCommits = Repository?.Head.TrackingDetails.AheadBy ?? 0;
+ PullCommits = snapshot.Behind;
+ PushCommits = snapshot.Ahead;
}
catch (Exception e)
{
ContainerLocator.Container.Resolve()?.Error(e.Message, e);
+ return;
}
StagedChanges.Merge(stagedChanges, (a, b) =>
@@ -201,4 +213,6 @@ public void Refresh(SourceControlViewModel sourceControlViewModel)
return false;
}, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
}
+
+ public void Dispose() => Repository.Dispose();
}
diff --git a/src/OneWare.SourceControl/Models/SourceControlFileModel.cs b/src/OneWare.SourceControl/Models/SourceControlFileModel.cs
index 353f989cc..693d21e41 100644
--- a/src/OneWare.SourceControl/Models/SourceControlFileModel.cs
+++ b/src/OneWare.SourceControl/Models/SourceControlFileModel.cs
@@ -1,10 +1,11 @@
using Avalonia.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
using LibGit2Sharp;
using OneWare.Essentials.Models;
namespace OneWare.SourceControl.Models;
-public class SourceControlFileModel
+public class SourceControlFileModel : ObservableObject
{
public SourceControlFileModel(string fullPath, StatusEntry change)
{
@@ -16,7 +17,11 @@ public SourceControlFileModel(string fullPath, StatusEntry change)
public string FullPath { get; }
- public StatusEntry Status { get; set; }
+ public StatusEntry Status
+ {
+ get;
+ set => SetProperty(ref field, value);
+ }
public string Name => Path.GetFileName(FullPath);
}
\ No newline at end of file
diff --git a/src/OneWare.SourceControl/SourceControlModule.cs b/src/OneWare.SourceControl/SourceControlModule.cs
index 12679ed79..4e56b2615 100644
--- a/src/OneWare.SourceControl/SourceControlModule.cs
+++ b/src/OneWare.SourceControl/SourceControlModule.cs
@@ -40,7 +40,7 @@ public override void Initialize(IServiceProvider serviceProvider)
settingsService.RegisterSetting("Team Explorer", "Fetch", "SourceControl_AutoFetchEnable",
new CheckBoxSetting("Auto fetch", true)
{
- HoverDescription = "Fetch for changed automatically"
+ HoverDescription = "Fetch remote changes automatically without prompting for login"
});
settingsService.RegisterSetting("Team Explorer", "Fetch", "SourceControl_AutoFetchDelay",
new SliderSetting("Auto fetch interval", 60, 5, 60, 5)
@@ -50,7 +50,7 @@ public override void Initialize(IServiceProvider serviceProvider)
settingsService.RegisterSetting("Team Explorer", "Polling", "SourceControl_PollChangesEnable",
new CheckBoxSetting("Poll for changes", true)
{
- HoverDescription = "Fetch for changed files automatically"
+ HoverDescription = "Refresh local file changes automatically"
});
settingsService.RegisterSetting("Team Explorer", "Polling", "SourceControl_PollChangesDelay",
new SliderSetting("Poll changes interval", 5, 1, 60, 1)
diff --git a/src/OneWare.SourceControl/ViewModels/CompareGitViewModel.cs b/src/OneWare.SourceControl/ViewModels/CompareGitViewModel.cs
index a4344333a..c58d13439 100644
--- a/src/OneWare.SourceControl/ViewModels/CompareGitViewModel.cs
+++ b/src/OneWare.SourceControl/ViewModels/CompareGitViewModel.cs
@@ -24,6 +24,9 @@ public class CompareGitViewModel : Document, IWaitForContent
{
private readonly IDisposable? _fileWatcher;
private readonly SourceControlViewModel _sourceControlViewModel;
+ private FileSystemWatcher? _indexWatcher;
+ private bool _closed;
+ private bool _reloadRequested;
public CompareGitViewModel(string fullPath, SourceControlViewModel sourceControlViewModel)
{
@@ -43,6 +46,12 @@ public bool IsLoading
public string LanguageExtension { get; }
+ public bool IsStaged { get; set; }
+
+ public string? RepositoryPath { get; set; }
+
+ public int ContextLines { get; set; } = 10000;
+
public ICollection? Chunks
{
get => field;
@@ -53,36 +62,70 @@ public ICollection? Chunks
public override bool OnClose()
{
+ _closed = true;
_fileWatcher?.Dispose();
+ _indexWatcher?.Dispose();
return base.OnClose();
}
public void InitializeContent()
{
+ if (_closed) return;
+ if (_indexWatcher == null && RepositoryPath != null)
+ {
+ try
+ {
+ // Git replaces index.lock with index atomically, rather than just writing index in place.
+ _indexWatcher = new FileSystemWatcher(RepositoryPath, "index")
+ {
+ NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName
+ };
+ _indexWatcher.Changed += OnIndexChanged;
+ _indexWatcher.Created += OnIndexChanged;
+ _indexWatcher.Deleted += OnIndexChanged;
+ _indexWatcher.Renamed += OnIndexChanged;
+ _indexWatcher.EnableRaisingEvents = true;
+ }
+ catch (Exception e)
+ {
+ // The repository may have been moved or deleted while the comparison tab remained open.
+ _indexWatcher?.Dispose();
+ _indexWatcher = null;
+ ContainerLocator.Container.Resolve().LogDebug(e, "Could not watch the Git index");
+ }
+ }
_ = LoadAsync();
}
+ private void OnIndexChanged(object sender, FileSystemEventArgs args) => Dispatcher.UIThread.Post(InitializeContent);
+
private async Task LoadAsync()
{
+ if (IsLoading)
+ {
+ _reloadRequested = true;
+ return;
+ }
IsLoading = true;
try
{
- var patch = _sourceControlViewModel.GetPatch(FullPath, 10000);
- if (patch != null)
- {
- await ParsePatchFileAsync(patch);
- }
- else
+ do
{
- Chunks = null;
- }
+ _reloadRequested = false;
+ using var patch = await Task.Run(() => _sourceControlViewModel.GetPatch(FullPath, ContextLines, IsStaged, RepositoryPath));
+ if (_closed) return;
+ if (patch != null) await ParsePatchFileAsync(patch);
+ else Chunks = null;
+ } while (_reloadRequested && !_closed);
}
catch (Exception e)
{
ContainerLocator.Container.Resolve().Error(e.Message, e);
}
-
- IsLoading = false;
+ finally
+ {
+ IsLoading = false;
+ }
}
private async Task ParsePatchFileAsync(Patch patchFile)
@@ -157,7 +200,7 @@ private async Task ParsePatchFileAsync(Patch patchFile)
return chunks;
});
- Chunks = result;
+ if (!_closed) Chunks = result;
}
private static List ResolveDiffSections(IEnumerable hunkElements)
@@ -166,7 +209,7 @@ private static List ResolveDiffSections(IEnumerable\d{1,})(\,(?\d{1,})){0,1}\s\+(?\d{1,})(\,(?\d{1,}){0,1})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
- var diffContents = hunkElements.Skip(3).Where(x => !x.StartsWith(@"\ No newline at end of file")).ToList();
+ var diffContents = hunkElements.Where(x => !x.StartsWith(@"\ No newline at end of file")).ToList();
var sectionHeaders = diffContents.Where(x => x.StartsWith("@@ ")).ToList();
var sections = new List();
diff --git a/src/OneWare.SourceControl/ViewModels/SourceControlViewModel.cs b/src/OneWare.SourceControl/ViewModels/SourceControlViewModel.cs
index 1451f2cf2..67ae58c38 100644
--- a/src/OneWare.SourceControl/ViewModels/SourceControlViewModel.cs
+++ b/src/OneWare.SourceControl/ViewModels/SourceControlViewModel.cs
@@ -1,17 +1,13 @@
using System.Collections.ObjectModel;
-using System.Text;
-using Avalonia;
-using Avalonia.Controls;
+using System.Reactive.Disposables;
using Avalonia.Controls.Notifications;
using Avalonia.Media;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
-using DynamicData;
using DynamicData.Binding;
using GitCredentialManager;
using LibGit2Sharp;
using Microsoft.Extensions.Logging;
-using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using OneWare.Essentials.Commands;
using OneWare.Essentials.Enums;
using OneWare.Essentials.Helpers;
@@ -24,7 +20,7 @@
namespace OneWare.SourceControl.ViewModels;
-public class SourceControlViewModel : ExtendedTool
+public class SourceControlViewModel : ExtendedTool, IDisposable
{
public const string IconKey = "BoxIcons.RegularGitBranch";
private readonly IApplicationStateService _applicationStateService;
@@ -44,7 +40,11 @@ public class SourceControlViewModel : ExtendedTool
private bool _isLoading;
- private DispatcherTimer? _timer;
+ private DispatcherTimer? _fetchTimer;
+ private DispatcherTimer? _pollTimer;
+ private readonly SemaphoreSlim _operationGate = new(1, 1);
+ private readonly CompositeDisposable _subscriptions = new();
+ private bool _disposed;
public SourceControlViewModel(ILogger logger, ISettingsService settingsService,
IApplicationStateService applicationStateService,
@@ -61,41 +61,43 @@ public SourceControlViewModel(ILogger logger, ISettingsService settingsService,
_windowService = windowService;
_projectExplorerService = projectExplorerService;
_paths = paths;
- _projectExplorerService = projectExplorerService;
FileIconService = fileIconService;
Id = "SourceControl";
InitializeRepositoryCommand =
- new AsyncRelayCommand(InitializeRepositoryAsync, () => _projectExplorerService.ActiveProject != null);
+ new AsyncRelayCommand(InitializeRepositoryAsync, () => !IsLoading && ActiveRepository == null && _projectExplorerService.ActiveProject != null);
RefreshAsyncCommand = new AsyncRelayCommand(RefreshAsync);
CloneDialogAsyncCommand = new AsyncRelayCommand(CloneDialogAsync);
- SyncAsyncCommand = new AsyncRelayCommand(SyncAsync, () => ActiveRepository != null);
- PullAsyncCommand = new AsyncRelayCommand(PullAsync);
- PushAsyncCommand = new AsyncRelayCommand(PushAsync); // new AsyncRelayCommand(PushAsync);
- FetchAsyncCommand = new AsyncRelayCommand(FetchAsync);
- CommitAsyncCommand = new AsyncRelayCommand(CommitAsync);
- DiscardAllAsyncCommand = new AsyncRelayCommand(DiscardAllAsync);
- StageAllCommand = new RelayCommand(StageAll);
- UnStageAllCommand = new RelayCommand(UnStageAll);
- StageCommand = new RelayCommand(Stage);
- UnStageCommand = new RelayCommand(UnStage);
- CreateBranchDialogAsyncCommand = new AsyncRelayCommand(CreateBranchDialogAsync, () => ActiveRepository != null);
- MergeBranchDialogAsyncCommand = new AsyncRelayCommand(MergeBranchDialogAsync);
- DeleteBranchDialogAsyncCommand = new AsyncRelayCommand(DeleteBranchDialogAsync);
- AddRemoteDialogAsyncCommand = new AsyncRelayCommand(AddRemoteDialogAsync);
- DeleteRemoteDialogAsyncCommand = new AsyncRelayCommand(DeleteRemoteDialogAsync);
- SetUserIdentityAsyncCommand = new AsyncRelayCommand(SetUserIdentityAsync);
-
- settingsService.GetSettingObservable("SourceControl_AutoFetchDelay")
- .Subscribe(SetupFetchTimer);
-
- settingsService.GetSettingObservable("SourceControl_PollChangesDelay")
- .Subscribe(SetupPollTimer);
-
- projectExplorerService
+ SyncAsyncCommand = new AsyncRelayCommand(SyncAsync, CanUseRepository);
+ PullAsyncCommand = new AsyncRelayCommand(PullAsync, CanUseRepository);
+ PushAsyncCommand = new AsyncRelayCommand(PushAsync, CanUseRepository);
+ FetchAsyncCommand = new AsyncRelayCommand(FetchAsync, CanUseRepository);
+ CommitAsyncCommand = new AsyncRelayCommand(CommitAsync, staged => CanUseRepository() &&
+ !string.IsNullOrWhiteSpace(CommitMessage) && ActiveRepository!.MergeChanges.Count == 0 &&
+ (ActiveRepository.StagedChanges.Count > 0 || (!staged && ActiveRepository.Changes.Count > 0) ||
+ ActiveRepository.Repository.Info.CurrentOperation == CurrentOperation.Merge));
+ DiscardAllAsyncCommand = new AsyncRelayCommand(DiscardAllAsync, _ => CanUseRepository());
+ StageAllCommand = new RelayCommand(StageAll, CanUseRepository);
+ UnStageAllCommand = new RelayCommand(UnStageAll, CanUseRepository);
+ StageCommand = new RelayCommand(Stage, path => CanUseRepository() && !string.IsNullOrWhiteSpace(path));
+ UnStageCommand = new RelayCommand(UnStage, path => CanUseRepository() && !string.IsNullOrWhiteSpace(path));
+ CreateBranchDialogAsyncCommand = new AsyncRelayCommand(CreateBranchDialogAsync, CanUseRepository);
+ MergeBranchDialogAsyncCommand = new AsyncRelayCommand(MergeBranchDialogAsync, CanUseRepository);
+ DeleteBranchDialogAsyncCommand = new AsyncRelayCommand(DeleteBranchDialogAsync, CanUseRepository);
+ AddRemoteDialogAsyncCommand = new AsyncRelayCommand(AddRemoteDialogAsync, CanUseRepository);
+ DeleteRemoteDialogAsyncCommand = new AsyncRelayCommand(DeleteRemoteDialogAsync, CanUseRepository);
+ SetUserIdentityAsyncCommand = new AsyncRelayCommand(SetUserIdentityAsync, _ => CanUseRepository());
+
+ _subscriptions.Add(settingsService.GetSettingObservable("SourceControl_AutoFetchDelay")
+ .Subscribe(SetupFetchTimer));
+
+ _subscriptions.Add(settingsService.GetSettingObservable("SourceControl_PollChangesDelay")
+ .Subscribe(SetupPollTimer));
+
+ _subscriptions.Add(projectExplorerService
.WhenValueChanged(x => x.ActiveProject)
- .Subscribe(RefreshAsyncCommand.Execute);
+ .Subscribe(project => { _ = RefreshAsync(); }));
_loginProviders.Add("github.com", ContainerLocator.Container.Resolve());
@@ -128,19 +130,28 @@ public SourceControlViewModel(ILogger logger, ISettingsService settingsService,
public GitRepositoryModel? ActiveRepository
{
get => _activeRepository;
- set => SetProperty(ref _activeRepository, value);
+ set
+ {
+ if (SetProperty(ref _activeRepository, value)) NotifyCommands();
+ }
}
public string CommitMessage
{
get => _commitMessage;
- set => SetProperty(ref _commitMessage, value);
+ set
+ {
+ if (SetProperty(ref _commitMessage, value)) CommitAsyncCommand.NotifyCanExecuteChanged();
+ }
}
public bool IsLoading
{
get => _isLoading;
- set => SetProperty(ref _isLoading, value);
+ private set
+ {
+ if (SetProperty(ref _isLoading, value)) NotifyCommands();
+ }
}
public AsyncRelayCommand InitializeRepositoryCommand { get; }
@@ -167,22 +178,23 @@ public override void InitializeContent()
{
base.InitializeContent();
- Title = "Commit";
+ Title = "Source Control";
}
private async Task RefreshAsync()
{
- InitializeRepositoryCommand.NotifyCanExecuteChanged();
-
- await WaitUntilFreeAsync();
-
- IsLoading = true;
-
- var removeInstances = Repositories.Where(x => !_projectExplorerService.Projects.Contains(x.Project)).ToArray();
- Repositories.RemoveMany(removeInstances);
-
+ await _operationGate.WaitAsync();
try
{
+ if (_disposed) return;
+ IsLoading = true;
+ var removeInstances = Repositories.Where(x => !_projectExplorerService.Projects.Contains(x.Project)).ToArray();
+ foreach (var removed in removeInstances)
+ {
+ Repositories.Remove(removed);
+ removed.Dispose();
+ }
+
foreach (var project in _projectExplorerService.Projects)
try
{
@@ -199,40 +211,126 @@ private async Task RefreshAsync()
_logger.Error(e.Message, e);
}
- foreach (var repo in Repositories)
- {
- //repo.Refresh(this);
- //TODO Show changes for all repos
- }
+ ActiveRepository = Repositories.FirstOrDefault(x => x.Project == _projectExplorerService.ActiveProject);
+ if (ActiveRepository != null) await ActiveRepository.RefreshAsync(this);
}
catch (Exception e)
{
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
+ _logger.Error(e.Message, e);
}
+ finally
+ {
+ EndOperation();
+ }
+ }
+
+ private bool CanUseRepository() => !_disposed && !IsLoading && ActiveRepository != null;
+
+ private void NotifyCommands()
+ {
+ InitializeRepositoryCommand.NotifyCanExecuteChanged();
+ SyncAsyncCommand.NotifyCanExecuteChanged();
+ PullAsyncCommand.NotifyCanExecuteChanged();
+ PushAsyncCommand.NotifyCanExecuteChanged();
+ FetchAsyncCommand.NotifyCanExecuteChanged();
+ CommitAsyncCommand.NotifyCanExecuteChanged();
+ DiscardAllAsyncCommand.NotifyCanExecuteChanged();
+ StageAllCommand.NotifyCanExecuteChanged();
+ UnStageAllCommand.NotifyCanExecuteChanged();
+ StageCommand.NotifyCanExecuteChanged();
+ UnStageCommand.NotifyCanExecuteChanged();
+ CreateBranchDialogAsyncCommand.NotifyCanExecuteChanged();
+ MergeBranchDialogAsyncCommand.NotifyCanExecuteChanged();
+ DeleteBranchDialogAsyncCommand.NotifyCanExecuteChanged();
+ AddRemoteDialogAsyncCommand.NotifyCanExecuteChanged();
+ DeleteRemoteDialogAsyncCommand.NotifyCanExecuteChanged();
+ SetUserIdentityAsyncCommand.NotifyCanExecuteChanged();
+ }
- ActiveRepository = Repositories.FirstOrDefault(x => x.Project == _projectExplorerService.ActiveProject);
+ private async Task RunRepositoryOperationAsync(Func operation)
+ {
+ var model = ActiveRepository;
+ if (model == null || _disposed) return;
+ await _operationGate.WaitAsync();
+ var started = false;
+ try
+ {
+ // Never run a queued command against a different or already closed project.
+ if (_disposed || ActiveRepository != model || !Repositories.Contains(model) ||
+ _projectExplorerService.ActiveProject != model.Project) return;
+ IsLoading = true;
+ started = true;
+ await operation(model.Repository);
+ }
+ catch (Exception e)
+ {
+ _logger.Error(e.Message, e);
+ }
+ finally
+ {
+ try
+ {
+ if (started && !_disposed && Repositories.Contains(model)) await model.RefreshAsync(this);
+ }
+ finally
+ {
+ EndOperation();
+ }
+ }
+ }
- ActiveRepository?.Refresh(this);
+ private void EndOperation()
+ {
+ try
+ {
+ if (_disposed)
+ {
+ foreach (var repository in Repositories) repository.Dispose();
+ Repositories.Clear();
+ ActiveRepository = null;
+ }
+ IsLoading = false;
+ NotifyCommands();
+ }
+ finally
+ {
+ _operationGate.Release();
+ }
+ }
- IsLoading = false;
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _fetchTimer?.Stop();
+ _pollTimer?.Stop();
+ _subscriptions.Dispose();
+ // An in-flight operation owns the native handles until it finishes.
+ if (_operationGate.Wait(0)) EndOperation();
}
#region Initialize and Clone
public async Task InitializeRepositoryAsync()
{
- if (_projectExplorerService.ActiveProject == null) return;
-
+ var project = _projectExplorerService.ActiveProject;
+ if (project == null || _disposed) return;
+ await _operationGate.WaitAsync();
try
{
- var rootPath = _projectExplorerService.ActiveProject.RootFolderPath;
- await Task.Run(() => Repository.Init(rootPath));
- await RefreshAsync();
+ if (_disposed) return;
+ IsLoading = true;
+ await Task.Run(() => Repository.Init(project.RootFolderPath));
}
catch (Exception e)
{
_logger.Error(e.Message, e);
}
+ finally
+ {
+ EndOperation();
+ }
+ await RefreshAsync();
}
public async Task CloneDialogAsync()
@@ -241,7 +339,19 @@ public async Task CloneDialogAsync()
"Enter the remote URL for the repository you want to clone", MessageBoxIcon.Info,
null, _mainDockService.GetWindowOwner(this));
- if (url == null) return;
+ if (string.IsNullOrWhiteSpace(url)) return;
+ url = url.Trim();
+
+ string name;
+ try
+ {
+ name = GitOperations.GetCloneDirectoryName(url);
+ }
+ catch (ArgumentException e)
+ {
+ _windowService.ShowNotification("Clone", e.Message, NotificationType.Warning);
+ return;
+ }
var folder = await _windowService.ShowFolderSelectAsync("Clone",
"Select the location for the new repository", MessageBoxIcon.Info, _paths.ProjectsDirectory,
@@ -249,30 +359,34 @@ public async Task CloneDialogAsync()
if (folder == null) return;
- folder = Path.Combine(folder, Path.GetFileNameWithoutExtension(url) ?? "");
- Directory.CreateDirectory(folder);
-
- var result = await CloneRepositoryAsync(url, folder);
-
- if (!result) return;
-
- await Task.Delay(200);
-
- var startFilePath = await StorageProviderHelper.SelectFilesAsync(_mainDockService.GetWindowOwner(this)!,
- "Open Project from cloned repository",
- folder);
-
- foreach (var file in startFilePath)
+ folder = Path.Combine(folder, name);
+ try
+ {
+ if (File.Exists(folder) || (Directory.Exists(folder) && Directory.EnumerateFileSystemEntries(folder).Any()))
+ {
+ _windowService.ShowNotification("Clone", "The destination already exists and is not empty. Choose another location.", NotificationType.Warning);
+ return;
+ }
+ if (!await CloneRepositoryAsync(url, folder)) return;
+ var manager = ContainerLocator.Container.Resolve().GetManager("Folder");
+ if (manager != null && await _windowService.ShowYesNoAsync("Clone Complete",
+ "Open the cloned repository as a folder project?", MessageBoxIcon.Info,
+ _mainDockService.GetWindowOwner(this)) == MessageBoxStatus.Yes)
+ {
+ await _projectExplorerService.LoadProjectAsync(folder, manager);
+ _mainDockService.Show(_projectExplorerService);
+ await RefreshAsync();
+ }
+ }
+ catch (Exception e)
{
- //var proj = await MainDock.ProjectFiles.LoadProjectAsync(file);
+ _logger.Error(e.Message, e);
}
}
private async Task CloneRepositoryAsync(string url, string destination)
{
- var success = true;
-
- var cancellationTokenSource = new CancellationTokenSource();
+ using var cancellationTokenSource = new CancellationTokenSource();
var key = _applicationStateService.AddState("Cloning " + Path.GetFileName(url) + "...", AppState.Loading,
() => cancellationTokenSource.Cancel());
@@ -286,23 +400,30 @@ await Task.Run(() =>
FetchOptions =
{
CredentialsProvider = (crUrl, usernameFromUrl, types) =>
- GetCredentialsAsync(crUrl, usernameFromUrl, types, cancellationTokenSource.Token).Result
+ GetCredentialsAsync(crUrl, usernameFromUrl, types, cancellationTokenSource.Token).GetAwaiter().GetResult(),
+ OnTransferProgress = _ => !cancellationTokenSource.IsCancellationRequested
},
RecurseSubmodules = true
};
Repository.Clone(url, destination, options);
}, cancellationTokenSource.Token);
+ cancellationTokenSource.Token.ThrowIfCancellationRequested();
+ return true;
+ }
+ catch (Exception) when (cancellationTokenSource.IsCancellationRequested)
+ {
+ _windowService.ShowNotification("Clone", "Cloning cancelled. Any downloaded files have been left in the destination folder.");
+ return false;
}
catch (Exception e)
{
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
-
- success = false;
+ _logger.Error(e.Message, e);
+ return false;
+ }
+ finally
+ {
+ _applicationStateService.RemoveState(key);
}
-
- _applicationStateService.RemoveState(key);
-
- return success;
}
#endregion
@@ -311,28 +432,30 @@ await Task.Run(() =>
private void SetupFetchTimer(double seconds)
{
- if (_timer != null && Math.Abs(_timer.Interval.TotalSeconds - seconds) < 1) return;
- _timer?.Stop();
- _timer = new DispatcherTimer(new TimeSpan(0, 0, (int)seconds), DispatcherPriority.Normal, FetchTimerCallback);
- _timer.Start();
+ _fetchTimer?.Stop();
+ if (_disposed || !double.IsFinite(seconds) || seconds <= 0) return;
+ _fetchTimer = new DispatcherTimer(TimeSpan.FromSeconds(seconds), DispatcherPriority.Normal, FetchTimerCallback);
+ _fetchTimer.Start();
}
private void FetchTimerCallback(object? sender, EventArgs args)
{
- if (_settingsService.GetSettingValue("SourceControl_AutoFetchEnable")) _ = FetchAsync();
+ if (CanUseRepository() && _settingsService.GetSettingValue("SourceControl_AutoFetchEnable"))
+ _ = FetchAsync(false);
}
private void SetupPollTimer(double seconds)
{
- if (_timer != null && Math.Abs(_timer.Interval.TotalSeconds - seconds) < 1) return;
- _timer?.Stop();
- _timer = new DispatcherTimer(new TimeSpan(0, 0, (int)seconds), DispatcherPriority.Normal, PollTimerCallback);
- _timer.Start();
+ _pollTimer?.Stop();
+ if (_disposed || !double.IsFinite(seconds) || seconds <= 0) return;
+ _pollTimer = new DispatcherTimer(TimeSpan.FromSeconds(seconds), DispatcherPriority.Normal, PollTimerCallback);
+ _pollTimer.Start();
}
private void PollTimerCallback(object? sender, EventArgs args)
{
- if (_settingsService.GetSettingValue("SourceControl_PollChangesEnable")) _ = RefreshAsync();
+ if (!_disposed && !IsLoading && _settingsService.GetSettingValue("SourceControl_PollChangesEnable"))
+ _ = RefreshAsync();
}
public void ViewInProjectExplorer(string fullPath)
@@ -354,403 +477,196 @@ public void ViewInProjectExplorer(string fullPath)
public void ChangeBranch(Branch? branch)
{
- if (ActiveRepository?.Repository is not { } repository || branch == null) return;
-
- try
+ if (branch == null || !CanUseRepository()) return;
+ _ = RunRepositoryOperationAsync(async repository =>
{
- if (branch.IsRemote)
- {
- var remoteBranch = branch;
- var branchName = branch.FriendlyName.Split("/");
-
- if (repository.Branches[branchName[1]] is { } localB)
- {
- branch = localB;
- }
- else
- {
- branch = repository.CreateBranch(branchName[1], branch.Tip);
- branch = repository.Branches.Update(branch,
- b => b.TrackedBranch = remoteBranch.CanonicalName);
- }
- }
-
- Commands.Checkout(repository, branch);
-
- _ = RefreshAsync();
-
- _logger.Log("Switched to branch '" + branch.FriendlyName + "'", true, Brushes.Green);
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- }
- }
-
- private async Task CreateBranchDialogAsync()
- {
- var newBranchName = await _windowService.ShowInputAsync("Create Branch",
- "Please enter a name for the new branch", MessageBoxIcon.Info, null, _mainDockService.GetWindowOwner(this));
- if (newBranchName != null) CreateBranch(newBranchName);
+ var checkedOut = await Task.Run(() => GitOperations.CheckoutBranch(repository, branch));
+ _logger.Log("Switched to branch '" + checkedOut.FriendlyName + "'", true, Brushes.Green);
+ });
}
- private Branch? CreateBranch(string name, bool checkout = true)
+ private Task CreateBranchDialogAsync()
{
- if (ActiveRepository?.Repository is not { } repository) return null;
-
- try
+ return RunRepositoryOperationAsync(async repository =>
{
- var newBranch = repository.CreateBranch(name);
-
- if (checkout) Commands.Checkout(repository, newBranch);
-
- _ = RefreshAsync();
-
- return newBranch;
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- return null;
- }
+ var name = await _windowService.ShowInputAsync("Create Branch",
+ "Please enter a name for the new branch", MessageBoxIcon.Info, null, _mainDockService.GetWindowOwner(this));
+ if (string.IsNullOrWhiteSpace(name)) return;
+ await Task.Run(() => Commands.Checkout(repository, repository.CreateBranch(name.Trim())));
+ });
}
- private async Task DeleteBranchDialogAsync()
+ private Task DeleteBranchDialogAsync()
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- var selectedBranchName = await _windowService.ShowInputSelectAsync("Delete Branch",
- "Select the branch you want to delete", MessageBoxIcon.Info,
- repository.Branches.Select(x => x.FriendlyName), repository.Branches.LastOrDefault()?.FriendlyName,
- _mainDockService.GetWindowOwner(this)) as string;
-
- if (selectedBranchName == null) return;
-
- var deleteBranch = repository.Branches
- .FirstOrDefault(x => x.FriendlyName == selectedBranchName);
-
- if (deleteBranch != null)
+ return RunRepositoryOperationAsync(async repository =>
{
- await DeleteBranchAsync(deleteBranch);
- _ = RefreshAsync();
- }
- }
-
- private async Task DeleteBranchAsync(Branch branch)
- {
- if (ActiveRepository?.Repository is not { } repository) return false;
+ var names = repository.Branches.Where(x => !x.IsCurrentRepositoryHead)
+ .Select(x => x.FriendlyName).OrderBy(x => x).ToArray();
+ var selected = await _windowService.ShowInputSelectAsync("Delete Branch",
+ "Select the branch you want to delete", MessageBoxIcon.Info, names, names.FirstOrDefault(),
+ _mainDockService.GetWindowOwner(this)) as string;
+ if (selected == null || repository.Branches[selected] is not { } branch) return;
+ var warning = branch.IsRemote
+ ? $"Delete remote branch '{selected}' from the server? This affects everyone using this repository."
+ : $"Delete local branch '{selected}'? Commits that have not been merged may become unreachable.";
+ if (await _windowService.ShowYesNoAsync("Delete Branch", warning, MessageBoxIcon.Warning,
+ _mainDockService.GetWindowOwner(this)) != MessageBoxStatus.Yes) return;
- try
- {
- repository.Branches.Remove(branch);
if (branch.IsRemote)
{
- await WaitUntilFreeAsync();
-
- IsLoading = true;
-
- await Task.Run(() =>
- {
- var remote = repository.Network.Remotes[branch.RemoteName];
- var pushRefSpec = $"+:refs/heads/{branch.FriendlyName.Split('/')[1]}";
- var options = new PushOptions
- {
- CredentialsProvider = (url, usernameFromUrl, types) =>
- GetCredentialsAsync(url, usernameFromUrl, types).Result
- };
- repository.Network.Push(remote, pushRefSpec, options);
- });
- IsLoading = false;
+ await Task.Run(() => GitOperations.DeleteRemoteBranch(repository, branch, CreatePushOptions()));
}
-
- return true;
- }
- catch (Exception e)
- {
- IsLoading = false;
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- return false;
- }
- }
-
- private async Task MergeBranchDialogAsync()
- {
- if (ActiveRepository?.Repository is not { } repository) return;
-
- var selectedBranchName = await _windowService.ShowInputSelectAsync("Merge Branch",
- "Select the branch to merge from", MessageBoxIcon.Info,
- repository.Branches.Select(x => x.FriendlyName), repository.Branches.LastOrDefault()?.FriendlyName,
- _mainDockService.GetWindowOwner(this)) as string;
-
- if (selectedBranchName == null) return;
-
- var mergeBranch = repository.Branches
- .FirstOrDefault(x => x.FriendlyName == selectedBranchName);
-
- if (mergeBranch != null)
- await MergeBranchAsync(mergeBranch);
+ else repository.Branches.Remove(branch);
+ });
}
- private async Task MergeBranchAsync(Branch source)
+ private Task MergeBranchDialogAsync()
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- try
- {
- var options = new MergeOptions();
- var result = repository.Merge(source.Tip, await GetSignatureAsync(repository), options);
- if (result != null) PublishMergeResult(result);
- }
- catch (Exception e)
+ return RunRepositoryOperationAsync(async repository =>
{
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- }
+ if (!CanIntegrate(repository)) return;
+ var names = repository.Branches.Where(x => !x.IsCurrentRepositoryHead).Select(x => x.FriendlyName).ToArray();
+ var selected = await _windowService.ShowInputSelectAsync("Merge Branch",
+ "Select the branch to merge from", MessageBoxIcon.Info, names, names.FirstOrDefault(),
+ _mainDockService.GetWindowOwner(this)) as string;
+ if (selected == null || repository.Branches[selected] is not { } source) return;
+ var signature = await GetSignatureAsync(repository);
+ if (signature == null) return;
+ var result = await Task.Run(() => repository.Merge(source.Tip, signature, new MergeOptions()));
+ PublishMergeResult(result);
+ });
}
- private async Task PublishBranchDialogAsync()
+ private async Task PublishBranchDialogAsync(Repository repository)
{
- if (ActiveRepository?.Repository is not { } repository) return false;
-
- IsLoading = true;
-
- bool success;
-
- try
- {
- if (repository.Head.IsTracking) return true;
-
- var result = await _windowService.ShowYesNoAsync("Info",
- $"The branch {repository.Head.FriendlyName} has no upstream branch. Would you like to publish this branch?",
- MessageBoxIcon.Info, _mainDockService.GetWindowOwner(this));
-
- if (result is MessageBoxStatus.Yes)
- {
- repository.Branches.Update(repository.Head,
- b => b.Remote = repository.Network.Remotes.First().Name,
- b => b.UpstreamBranch = repository.Head.CanonicalName);
-
- await Task.Run(() =>
- {
- repository.Network.Push(repository.Head, new PushOptions
- {
- CredentialsProvider = (url, usernameFromUrl, types) =>
- GetCredentialsAsync(url, usernameFromUrl, types).Result
- });
- });
-
- _windowService.ShowNotification("Git Info",
- $"Branch {repository.Head.FriendlyName} published successfully!", NotificationType.Success);
-
- success = true;
- }
-
- success = false;
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- success = false;
- }
-
- IsLoading = false;
-
- return success;
+ if (repository.Head.IsTracking) return true;
+ if (!CanPush(repository)) return false;
+ if (!repository.Network.Remotes.Any() && !await AddRemoteAsync(repository)) return false;
+
+ if (await _windowService.ShowYesNoAsync("Publish Branch",
+ $"The branch {repository.Head.FriendlyName} has no upstream branch. Would you like to publish it?",
+ MessageBoxIcon.Info, _mainDockService.GetWindowOwner(this)) != MessageBoxStatus.Yes) return false;
+
+ var remotes = repository.Network.Remotes.Select(x => x.Name).ToArray();
+ var remoteName = remotes.Length == 1 ? remotes[0] :
+ await _windowService.ShowInputSelectAsync("Publish Branch", "Select the destination remote",
+ MessageBoxIcon.Info, remotes, remotes.Contains("origin") ? "origin" : remotes[0],
+ _mainDockService.GetWindowOwner(this)) as string;
+ if (remoteName == null) return false;
+
+ var head = await Task.Run(() => GitOperations.PublishBranch(repository, remoteName, CreatePushOptions()));
+ _windowService.ShowNotification("Git Info", $"Branch {head.FriendlyName} published successfully!", NotificationType.Success);
+ return true;
}
- private async Task AddRemoteDialogAsync()
- {
- if (ActiveRepository?.Repository is not { } repository) return false;
-
- if (!repository.Head.IsTracking)
- {
- var url = await _windowService.ShowInputAsync("Add Remote", "Please enter the repository URL",
- MessageBoxIcon.Info,
- null, _mainDockService.GetWindowOwner(this));
- if (url == null) return false;
-
- var remoteName = await _windowService.ShowInputAsync("Add Remote",
- "Please enter a name for the remote. If this is the first remote you can leave the name as origin.",
- MessageBoxIcon.Info,
- "origin", _mainDockService.GetWindowOwner(this));
- if (remoteName == null) return false;
-
- return AddRemote(url, remoteName);
- }
-
- return false;
- }
+ private Task AddRemoteDialogAsync() => RunRepositoryOperationAsync(async repository => { await AddRemoteAsync(repository); });
- private bool AddRemote(string url, string name)
+ private async Task AddRemoteAsync(Repository repository)
{
- if (ActiveRepository?.Repository is not { } repository) return false;
-
- if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(name)) return false;
-
- try
- {
- var remote = repository.Network.Remotes.Add(name, url);
- if (remote != null) return true;
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- }
-
- return false;
+ var url = await _windowService.ShowInputAsync("Add Remote", "Please enter the repository URL",
+ MessageBoxIcon.Info, null, _mainDockService.GetWindowOwner(this));
+ if (string.IsNullOrWhiteSpace(url)) return false;
+ var name = await _windowService.ShowInputAsync("Add Remote", "Please enter a name for the remote",
+ MessageBoxIcon.Info, repository.Network.Remotes["origin"] == null ? "origin" : null,
+ _mainDockService.GetWindowOwner(this));
+ if (string.IsNullOrWhiteSpace(name)) return false;
+ repository.Network.Remotes.Add(name.Trim(), url.Trim());
+ return true;
}
- private async Task DeleteRemoteDialogAsync()
+ private Task DeleteRemoteDialogAsync()
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- if (await _windowService.ShowInputSelectAsync("Delete Remote",
+ return RunRepositoryOperationAsync(async repository =>
+ {
+ if (await _windowService.ShowInputSelectAsync("Delete Remote",
"Select the remote you want to delete", MessageBoxIcon.Info,
repository.Network.Remotes.Select(x => x.Name), repository.Network.Remotes.LastOrDefault()?.Name,
- _mainDockService.GetWindowOwner(this)) is string selectedRemoteName)
- DeleteRemote(selectedRemoteName);
- }
-
- private bool DeleteRemote(string name)
- {
- if (ActiveRepository?.Repository is not { } repository) return false;
-
- if (string.IsNullOrEmpty(name)) return false;
-
- try
- {
- repository.Network.Remotes.Remove(name);
- return true;
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- return false;
- }
+ _mainDockService.GetWindowOwner(this)) is not string name) return;
+ if (await _windowService.ShowYesNoAsync("Delete Remote", $"Remove remote '{name}' from this repository?",
+ MessageBoxIcon.Warning, _mainDockService.GetWindowOwner(this)) == MessageBoxStatus.Yes)
+ repository.Network.Remotes.Remove(name);
+ });
}
#endregion
#region Commit & Sync
- private async Task CommitAsync(bool staged)
+ private Task CommitAsync(bool staged)
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- try
+ var message = CommitMessage;
+ return RunRepositoryOperationAsync(async repository =>
{
- if (!staged) Commands.Stage(repository, "*");
-
+ if (string.IsNullOrWhiteSpace(message)) return;
var author = await GetSignatureAsync(repository);
- var committer = author;
- var commit = repository.Commit(CommitMessage, author, committer);
-
+ if (author == null) return;
+ var commit = await Task.Run(() => GitOperations.Commit(repository, message, author, staged));
_logger.Log($"Commit {commit.Message}", true, Brushes.Green);
- CommitMessage = "";
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- }
-
- _ = RefreshAsync();
- _ = FetchAsync();
+ if (CommitMessage == message) CommitMessage = "";
+ });
}
- public async Task SyncAsync()
+ public Task SyncAsync()
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- var push = true;
- if (!repository.Network.Remotes.Any())
+ return RunRepositoryOperationAsync(async repository =>
{
- var result = await _windowService.ShowYesNoAsync("Warning",
- "This repository does not have a remote. Do you want to add one?", MessageBoxIcon.Warning,
- _mainDockService.GetWindowOwner(this));
- if (result is MessageBoxStatus.Yes)
- {
- if (!await AddRemoteDialogAsync()) return;
- }
- else
+ if (!CanPush(repository) || !CanIntegrate(repository)) return;
+ if (!repository.Head.IsTracking)
{
+ await PublishBranchDialogAsync(repository);
return;
}
- }
+ var result = await PullCoreAsync(repository);
+ if (result != null && result.Status != MergeStatus.Conflicts)
+ await PushCoreAsync(repository);
+ });
+ }
- if (!repository.Head.IsTracking)
- {
- if (!await PublishBranchDialogAsync()) return;
- }
- else
+ private bool CanIntegrate(Repository repository)
+ {
+ if (repository.Index.Conflicts.Any() || repository.Info.CurrentOperation != CurrentOperation.None)
{
- var mergeResult = await PullAsync();
- if (mergeResult == null || mergeResult.Status == MergeStatus.Conflicts) push = false;
+ _windowService.ShowNotification("Git Warning", "Finish the current merge or resolve conflicts before pulling or merging.", NotificationType.Warning);
+ return false;
}
+ return true;
+ }
- if (push)
+ private bool CanPush(Repository repository)
+ {
+ if (repository.Info.IsHeadDetached || repository.Head.Tip == null)
{
- var pushResult = await PushAsync();
- //if (pushResult)
- //_windowService.ShowNotification("Success", "Sync finished successfully", NotificationType.Success);
- _ = RefreshAsync();
+ _windowService.ShowNotification("Git Warning", "Check out a branch with at least one commit before pushing.", NotificationType.Warning);
+ return false;
}
+ return true;
}
- private async Task WaitUntilFreeAsync()
- {
- while (IsLoading) await Task.Delay(100);
- }
+ private Task PullAsync() => RunRepositoryOperationAsync(async repository => { await PullCoreAsync(repository); });
- private async Task PullAsync()
+ private async Task PullCoreAsync(Repository repository)
{
- if (ActiveRepository?.Repository is not { } repository) return null;
-
- var pullState =
- _applicationStateService.AddState("Pulling from " + repository.Head.RemoteName, AppState.Loading);
- await WaitUntilFreeAsync();
- IsLoading = true;
-
- //foreach(var terminal in MainDock.Terminals)
- //{
- // terminal.CloseConnection();
- //}
-
- var signature = await GetSignatureAsync(repository);
-
- var result = await Task.Run(() =>
+ if (!CanIntegrate(repository)) return null;
+ if (!repository.Head.IsTracking)
{
- try
- {
- // Credential information to fetch
- var options = new PullOptions
- {
- FetchOptions = new FetchOptions
- {
- CredentialsProvider = (url, usernameFromUrl, types) =>
- GetCredentialsAsync(url, usernameFromUrl, types).Result
- }
- };
-
- var mergeResult = Commands.Pull(repository, signature, options);
-
- return mergeResult;
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- return null;
- }
- });
-
- IsLoading = false;
- _applicationStateService.RemoveState(pullState);
-
- if (result != null)
+ _windowService.ShowNotification("Git Info", "This branch has no upstream. Publish it with Push, or check out a remote branch.");
+ return null;
+ }
+ var signature = await GetSignatureAsync(repository);
+ if (signature == null) return null;
+ var state = _applicationStateService.AddState("Pulling from " + repository.Head.RemoteName, AppState.Loading);
+ try
{
+ var result = await Task.Run(() => Commands.Pull(repository, signature,
+ new PullOptions { FetchOptions = CreateFetchOptions() }));
_logger.Log($"Pull Status: {result.Status}", true);
PublishMergeResult(result);
+ return result;
+ }
+ finally
+ {
+ _applicationStateService.RemoveState(state);
}
-
- return result;
}
private void PublishMergeResult(MergeResult result)
@@ -774,204 +690,132 @@ private void PublishMergeResult(MergeResult result)
}
}
- private async Task PushAsync()
- {
- if (ActiveRepository?.Repository is not { } repository) return false;
+ private Task PushAsync() => RunRepositoryOperationAsync(PushCoreAsync);
+ private async Task PushCoreAsync(Repository repository)
+ {
+ if (!CanPush(repository)) return;
if (!repository.Head.IsTracking)
{
- var success = await PublishBranchDialogAsync();
- if (!success) return false;
- }
-
- if (ActiveRepository.PushCommits == 0)
- {
- _logger.Log("Nothing to push");
- return true;
+ await PublishBranchDialogAsync(repository);
+ return;
}
-
- var pullState =
- _applicationStateService.AddState("Pushing to " + repository.Head.RemoteName, AppState.Loading);
- await WaitUntilFreeAsync();
- IsLoading = true;
-
- var result = await Task.Run(() =>
+ // Do not skip a push based on stale ahead/behind counts from the UI.
+ var state = _applicationStateService.AddState("Pushing to " + repository.Head.RemoteName, AppState.Loading);
+ try
{
- try
- {
- var pushOptions = new PushOptions
- {
- CredentialsProvider = (url, usernameFromUrl, types) =>
- GetCredentialsAsync(url, usernameFromUrl, types).Result
- };
- //PUSH
- repository.Network.Push(repository.Head, pushOptions);
- return true;
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- return false;
- }
- });
-
- _applicationStateService.RemoveState(pullState);
- IsLoading = false;
-
- if (result)
+ await Task.Run(() => repository.Network.Push(repository.Head, CreatePushOptions()));
_windowService.ShowNotification("Git Info", $"Pushed successfully to {repository.Head.FriendlyName}",
NotificationType.Success);
-
- return result;
+ }
+ finally
+ {
+ _applicationStateService.RemoveState(state);
+ }
}
- private async Task FetchAsync()
- {
- if (ActiveRepository?.Repository is not { } repository) return;
-
- await WaitUntilFreeAsync();
- IsLoading = true;
+ private Task FetchAsync() => FetchAsync(true);
- await Task.Run(() =>
+ private Task FetchAsync(bool interactive)
+ {
+ return RunRepositoryOperationAsync(async repository =>
{
- try
- {
- var logMessage = "";
- var options = new FetchOptions
- {
- CredentialsProvider = (url, usernameFromUrl, types) =>
- GetCredentialsAsync(url, usernameFromUrl, types).Result
- };
-
- foreach (var remote in repository.Network.Remotes)
- {
- var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification);
- Commands.Fetch(repository, remote.Name, refSpecs, options, logMessage);
- }
-
- ActiveRepository.PullCommits = repository.Head.TrackingDetails.BehindBy ?? 0;
- ActiveRepository.PushCommits = repository.Head.TrackingDetails.AheadBy ?? 0;
- }
- catch (Exception e)
+ foreach (var remote in repository.Network.Remotes)
{
- if (_settingsService.GetSettingValue("SourceControl_AutoFetchEnable"))
+ try
{
- ContainerLocator.Container.Resolve()
- ?.Error(e.Message + "\nAutomatic fetching disabled!", e);
- _settingsService.SetSettingValue("SourceControl_AutoFetchEnable", false);
+ await Task.Run(() => Commands.Fetch(repository, remote.Name,
+ remote.FetchRefSpecs.Select(x => x.Specification), CreateFetchOptions(interactive), ""));
}
- else
+ catch (Exception e)
{
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
+ if (interactive) _logger.Error(e.Message, e);
+ else _logger.LogDebug(e, "Automatic Git fetch failed for remote {Remote}", remote.Name);
}
}
});
-
- IsLoading = false;
}
+ // LibGit2Sharp requires synchronous callbacks. These are only invoked on worker threads.
+ private FetchOptions CreateFetchOptions(bool interactive = true) => new()
+ {
+ Prune = true,
+ CredentialsProvider = (url, username, types) =>
+ GetCredentialsAsync(url, username, types, interactive: interactive).GetAwaiter().GetResult()
+ };
+
+ private PushOptions CreatePushOptions() => new()
+ {
+ CredentialsProvider = (url, username, types) => GetCredentialsAsync(url, username, types).GetAwaiter().GetResult(),
+ OnPushStatusError = error => throw new InvalidOperationException($"Push rejected for {error.Reference}: {error.Message}")
+ };
+
#endregion
#region Stage & Discard
private void StageAll()
{
- if (ActiveRepository?.Repository is not { } repository) return;
- Commands.Stage(repository, "*");
- _ = RefreshAsync();
+ if (!CanUseRepository()) return;
+ _ = RunRepositoryOperationAsync(repository => Task.Run(() => Commands.Stage(repository, "*")));
}
private void UnStageAll()
{
- if (ActiveRepository?.Repository is not { } repository) return;
- Commands.Unstage(repository, "*");
- _ = RefreshAsync();
+ if (!CanUseRepository()) return;
+ _ = RunRepositoryOperationAsync(repository => Task.Run(() => Commands.Unstage(repository, "*")));
}
public void Stage(string? path)
{
- if (ActiveRepository?.Repository is not { } repository) return;
- Commands.Stage(repository, path);
-
- _ = RefreshAsync();
+ if (!CanUseRepository() || string.IsNullOrWhiteSpace(path)) return;
+ _ = RunRepositoryOperationAsync(repository => Task.Run(() =>
+ Commands.Stage(repository, GitOperations.GetRelativePath(repository, path))));
}
public void UnStage(string? path)
{
- if (ActiveRepository?.Repository is not { } repository) return;
- Commands.Unstage(repository, path);
-
- _ = RefreshAsync();
+ if (!CanUseRepository() || string.IsNullOrWhiteSpace(path)) return;
+ _ = RunRepositoryOperationAsync(repository => Task.Run(() =>
+ Commands.Unstage(repository, GitOperations.GetRelativePath(repository, path))));
}
- public async Task DiscardAsync(string path)
+ public Task DiscardAsync(string path)
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- var options = new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force };
- repository.CheckoutPaths(repository.Head.FriendlyName, new[] { path }, options);
-
- if (!Path.IsPathRooted(path)) path = Path.Combine(repository.Info.WorkingDirectory, path);
-
- var entry = ActiveRepository.Changes
- .FirstOrDefault(x => Path.Combine(repository.Info.WorkingDirectory, x.Status.FilePath) == path);
- if (entry is { Status.State: FileStatus.NewInWorkdir })
+ return RunRepositoryOperationAsync(async repository =>
{
- var result = await _windowService.ShowYesNoCancelAsync("Warning",
- $"Are you sure you want to delete {Path.GetFileName(path)}?", MessageBoxIcon.Warning);
-
- if (result is MessageBoxStatus.Yes)
- try
- {
- File.Delete(path);
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- }
- }
-
- await RefreshAsync();
+ var relativePath = GitOperations.GetRelativePath(repository, path);
+ var untracked = repository.RetrieveStatus(relativePath).HasFlag(FileStatus.NewInWorkdir);
+ var message = untracked
+ ? $"Permanently delete untracked file '{relativePath}'? This cannot be undone."
+ : $"Discard unstaged changes to '{relativePath}'? Staged changes will be kept. This cannot be undone.";
+ if (await _windowService.ShowYesNoAsync("Discard Changes", message, MessageBoxIcon.Warning,
+ _mainDockService.GetWindowOwner(this)) != MessageBoxStatus.Yes) return;
+ await Task.Run(() =>
+ {
+ if (untracked) File.Delete(Path.Combine(repository.Info.WorkingDirectory, relativePath));
+ else GitOperations.DiscardWorkingTreeFile(repository, relativePath);
+ });
+ });
}
- private async Task DiscardAllAsync(ResetMode mode)
+ private Task DiscardAllAsync(ResetMode mode)
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- try
+ return RunRepositoryOperationAsync(async repository =>
{
- await WaitUntilFreeAsync();
- repository.Reset(mode);
-
if (mode == ResetMode.Hard)
{
- var deleteFiles = new List();
- foreach (var item in repository.RetrieveStatus(new StatusOptions()))
- if (item.State == FileStatus.NewInWorkdir)
- {
- var path = Path.Combine(repository.Info.WorkingDirectory, item.FilePath);
- deleteFiles.Add(path);
- }
-
- if (deleteFiles.Any())
+ if (repository.Head.Tip == null)
{
- var result = await _windowService.ShowYesNoCancelAsync("Warning",
- $"Do you want to delete {deleteFiles.Count} untracked files forever?", MessageBoxIcon.Warning);
-
- if (result is MessageBoxStatus.Yes)
- foreach (var f in deleteFiles)
- {
- File.Delete(f);
- }
+ _windowService.ShowNotification("Git Info", "There is no commit to reset to. Unstage or discard individual files instead.");
+ return;
}
+ if (await _windowService.ShowYesNoAsync("Discard All Changes",
+ "Discard ALL staged and unstaged changes to tracked files? This cannot be undone. Untracked files will be kept.",
+ MessageBoxIcon.Warning, _mainDockService.GetWindowOwner(this)) != MessageBoxStatus.Yes) return;
}
-
- _ = RefreshAsync();
- }
- catch (Exception e)
- {
- ContainerLocator.Container.Resolve()?.Error(e.Message, e);
- }
+ await Task.Run(() => GitOperations.ResetTrackedChanges(repository, mode));
+ });
}
#endregion
@@ -987,28 +831,29 @@ private async Task DiscardAllAsync(ResetMode mode)
return path;
}
- public async Task OpenHeadFileAsync(string path)
+ public Task OpenHeadFileAsync(string path)
{
- if (ActiveRepository?.Repository is not { } repository) return;
-
- string commitContent;
- var blob = repository.Head.Tip[path].Target as Blob;
-
- if (blob == null) throw new NullReferenceException(nameof(blob));
-
- using (var content = new StreamReader(blob.GetContentStream(), Encoding.UTF8))
+ return RunRepositoryOperationAsync(async repository =>
{
- commitContent = await content.ReadToEndAsync();
- }
-
- var evm = await _mainDockService.OpenFileAsync(path);
-
- if (evm is IEditor editor)
- {
- editor.Title += " (HEAD)";
- editor.IsReadOnly = true;
- editor.CurrentDocument.Text = commitContent;
- }
+ var relativePath = GitOperations.GetRelativePath(repository, path);
+ if (repository.Head.Tip?[relativePath]?.Target is not Blob blob)
+ {
+ _windowService.ShowNotification("Git Info", "This file does not exist in HEAD.");
+ return;
+ }
+ // A snapshot must never reuse (and overwrite) an open working-tree editor.
+ var folder = Path.Combine(_paths.TempDirectory, "Git", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(folder);
+ var snapshotPath = Path.Combine(folder, Path.GetFileName(relativePath));
+ using (var content = blob.GetContentStream())
+ await using (var file = File.Create(snapshotPath))
+ await content.CopyToAsync(file);
+ if (await _mainDockService.OpenFileAsync(snapshotPath) is IEditor editor)
+ {
+ editor.Title = Path.GetFileName(relativePath) + " (HEAD)";
+ editor.IsReadOnly = true;
+ }
+ });
}
public void CompareAndSwitch(string path)
@@ -1017,6 +862,12 @@ public void CompareAndSwitch(string path)
Compare(path, true);
}
+ public void CompareStagedAndSwitch(string path)
+ {
+ if (ActiveRepository?.Repository is not { } repository) return;
+ _ = CompareChangesAsync(repository, path, "Staged: ", 10000, staged: true);
+ }
+
public void Compare(string path, bool switchTab)
{
if (ActiveRepository?.Repository is not { } repository) return;
@@ -1029,33 +880,37 @@ public void ViewChanges(string path)
_ = CompareChangesAsync(repository, path, "Changes: ");
}
- public Patch? GetPatch(string path, int contextLines)
+ public Patch? GetPatch(string path, int contextLines, bool staged = false, string? repositoryPath = null)
{
- if (ActiveRepository?.Repository is not { } repository) return null;
-
- return repository.Diff.Compare(new List { path }, false,
- new ExplicitPathsOptions(),
- new CompareOptions { ContextLines = contextLines });
+ repositoryPath ??= Repository.Discover(Path.GetDirectoryName(path));
+ if (repositoryPath == null) return null;
+ using var repository = new Repository(repositoryPath);
+ return GitOperations.GetPatch(repository, path, contextLines, staged);
}
private async Task CompareChangesAsync(Repository repository, string path, string titlePrefix, int contextLines = 3,
- bool switchTab = true)
+ bool switchTab = true, bool staged = false)
{
- await WaitUntilFreeAsync();
try
{
+ var repositoryPath = repository.Info.Path;
var fullPath = Path.IsPathRooted(path)
? path
: Path.Combine(repository.Info.WorkingDirectory, path.Replace('/', Path.DirectorySeparatorChar));
var openTab = _mainDockService.SearchView()
- .FirstOrDefault(x => x.FullPath == fullPath);
+ .FirstOrDefault(x => x.FullPath == fullPath && x.IsStaged == staged);
openTab ??= ContainerLocator.Container.Resolve((typeof(string), fullPath));
+ openTab.RepositoryPath = repositoryPath;
+ openTab.IsStaged = staged;
+ openTab.ContextLines = contextLines;
openTab.Title = titlePrefix + Path.GetFileName(path);
openTab.Id = titlePrefix + fullPath;
_mainDockService.Show(openTab, DockShowLocation.Document);
+ openTab.InitializeContent();
+ await Task.CompletedTask;
}
catch (Exception e)
{
@@ -1111,39 +966,30 @@ await Dispatcher.UIThread.InvokeAsync(() => _windowService.ShowDialogAsync(new A
}
private async Task GetCredentialsAsync(string url, string usernameFromUrl,
- SupportedCredentialTypes types, CancellationToken cancellationToken = default)
+ SupportedCredentialTypes types, CancellationToken cancellationToken = default, bool interactive = true)
{
- if (types.HasFlag(SupportedCredentialTypes.UsernamePassword))
+ cancellationToken.ThrowIfCancellationRequested();
+ if (types.HasFlag(SupportedCredentialTypes.UsernamePassword) && Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
- var ub = new Uri(url);
-
- var username = _settingsService.GetSettingValue(SourceControlModule.GitHubAccountNameKey);
-
var store = CredentialManager.Create("oneware");
-
- if (!string.IsNullOrWhiteSpace(username))
+ // One login attempt only; successful authentication may still fail to save credentials.
+ for (var attempt = 0; attempt < 2; attempt++)
{
- var key = $"{ub.Scheme}://{ub.Host}";
- var cred = store.Get(key, username);
-
- if (cred != null)
- return new UsernamePasswordCredentials
- {
- Username = cred.Account,
- Password = cred.Password
- };
+ cancellationToken.ThrowIfCancellationRequested();
+ var username = uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)
+ ? _settingsService.GetSettingValue(SourceControlModule.GitHubAccountNameKey)
+ : usernameFromUrl;
+ if (!string.IsNullOrWhiteSpace(username))
+ {
+ var cred = store.Get($"{uri.Scheme}://{uri.Host}", username);
+ if (cred != null)
+ return new UsernamePasswordCredentials { Username = cred.Account, Password = cred.Password };
+ }
+ if (!interactive || attempt != 0 || !_loginProviders.TryGetValue(uri.Host, out var loginProvider) ||
+ !await LoginDialogAsync(loginProvider)) break;
}
-
- var loginResult = false;
-
- if (_loginProviders.TryGetValue(ub.Host, out var loginProvider))
- loginResult = await LoginDialogAsync(loginProvider);
-
- if (cancellationToken.IsCancellationRequested) return new DefaultCredentials();
-
- if (loginResult) return await GetCredentialsAsync(url, usernameFromUrl, types, cancellationToken);
}
-
+ cancellationToken.ThrowIfCancellationRequested();
return new DefaultCredentials();
}
@@ -1157,8 +1003,8 @@ private async Task GetCredentialsAsync(string url, string usernameF
if (author == null)
{
- var identity = await SetUserIdentityAsync(true);
-
+ var identity = await SetUserIdentityCoreAsync(repository, true);
+ if (identity == null) return null;
author = new Signature(identity, DateTime.Now);
}
@@ -1174,7 +1020,7 @@ private async Task GetCredentialsAsync(string url, string usernameF
if (name == null) return null;
var email = await _windowService.ShowInputAsync("Info",
- "Please enter a valid email adress to sign your changes", MessageBoxIcon.Info, author?.Email);
+ "Please enter a valid email address to sign your changes", MessageBoxIcon.Info, author?.Email);
if (email == null) return null;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(email))
@@ -1183,13 +1029,16 @@ private async Task GetCredentialsAsync(string url, string usernameF
return null;
}
- return new Identity(name, email);
+ return new Identity(name.Trim(), email.Trim());
}
- private async Task SetUserIdentityAsync(bool dialog)
+ private Task SetUserIdentityAsync(bool dialog) => RunRepositoryOperationAsync(async repository =>
{
- if (ActiveRepository?.Repository is not { } repository) return null;
+ await SetUserIdentityCoreAsync(repository, dialog);
+ });
+ private async Task SetUserIdentityCoreAsync(Repository repository, bool dialog)
+ {
var identity = await GetIdentityManualAsync(repository);
if (identity == null) return null;
@@ -1209,8 +1058,11 @@ private async Task GetCredentialsAsync(string url, string usernameF
var globalConfig =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".gitconfig");
- await File.WriteAllTextAsync(globalConfig,
- $"[user]\n\tname = {identity.Name}\n\temail = {identity.Email}\n", Encoding.UTF8);
+ // Never overwrite existing configuration; let libgit2 escape identity values.
+ using (File.Open(globalConfig, FileMode.OpenOrCreate, FileAccess.Write)) { }
+ using var config = Configuration.BuildFrom(repository.Info.Path, globalConfig);
+ config.Set("user.name", identity.Name, ConfigurationLevel.Global);
+ config.Set("user.email", identity.Email, ConfigurationLevel.Global);
}
catch (Exception e)
{
diff --git a/src/OneWare.SourceControl/Views/SourceControlMainWindowBottomRightExtension.axaml b/src/OneWare.SourceControl/Views/SourceControlMainWindowBottomRightExtension.axaml
index 209df5725..db8e92946 100644
--- a/src/OneWare.SourceControl/Views/SourceControlMainWindowBottomRightExtension.axaml
+++ b/src/OneWare.SourceControl/Views/SourceControlMainWindowBottomRightExtension.axaml
@@ -10,7 +10,7 @@
-