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 @@ - @@ -140,7 +140,7 @@ - + + Command="{Binding CloneDialogAsyncCommand}"> - + - @@ -263,7 +262,6 @@ Command="{Binding #SourceControlViewView.((viewModels:SourceControlViewModel)DataContext).OpenFileAsync}" CommandParameter="{Binding Status.FilePath}" /> @@ -296,8 +294,7 @@ - @@ -313,7 +310,6 @@ Command="{Binding #SourceControlViewView.((viewModels:SourceControlViewModel)DataContext).OpenFileAsync}" CommandParameter="{Binding Status.FilePath}" /> @@ -343,6 +339,7 @@ diff --git a/src/OneWare.SourceControl/Views/SourceControlView.axaml.cs b/src/OneWare.SourceControl/Views/SourceControlView.axaml.cs index af847dbef..e55e5deef 100644 --- a/src/OneWare.SourceControl/Views/SourceControlView.axaml.cs +++ b/src/OneWare.SourceControl/Views/SourceControlView.axaml.cs @@ -24,7 +24,8 @@ public void OnChangeDoubleTap(object? sender, RoutedEventArgs e) public void OnStagedChangeDoubleTap(object? sender, RoutedEventArgs e) { - if (StagedChangeListBox.SelectedItem is SourceControlFileModel scm) _ = AutoOpenAsync(scm); + if (StagedChangeListBox.SelectedItem is SourceControlFileModel scm && DataContext is SourceControlViewModel vm) + vm.CompareStagedAndSwitch(scm.Status.FilePath); } public void OnMergeChangeDoubleTap(object? sender, RoutedEventArgs e) @@ -34,12 +35,8 @@ public void OnMergeChangeDoubleTap(object? sender, RoutedEventArgs e) public async Task AutoOpenAsync(SourceControlFileModel scm) { - if (!(DataContext is SourceControlViewModel vm)) return; - if (scm.Status.State == FileStatus.ModifiedInWorkdir || scm.Status.State == FileStatus.ModifiedInIndex) - vm.CompareAndSwitch(scm.Status.FilePath); - else if (scm.Status.State == FileStatus.DeletedFromWorkdir) await vm.OpenHeadFileAsync(scm.Status.FilePath); - else if (scm.Status.State == FileStatus.NewInWorkdir || scm.Status.State == FileStatus.NewInIndex) - await vm.OpenFileAsync(scm.Status.FilePath); - else if (scm.Status.State == FileStatus.Conflicted) await vm.OpenFileAsync(scm.Status.FilePath); + if (DataContext is not SourceControlViewModel vm || vm.IsLoading) return; + if (scm.Status.State.HasFlag(FileStatus.Conflicted)) await vm.OpenFileAsync(scm.Status.FilePath); + else vm.CompareAndSwitch(scm.Status.FilePath); } } \ No newline at end of file diff --git a/tests/OneWare.SourceControl.UnitTests/GitOperationsTests.cs b/tests/OneWare.SourceControl.UnitTests/GitOperationsTests.cs new file mode 100644 index 000000000..bc3ce6c72 --- /dev/null +++ b/tests/OneWare.SourceControl.UnitTests/GitOperationsTests.cs @@ -0,0 +1,385 @@ +using LibGit2Sharp; +using OneWare.SourceControl.Converters; +using OneWare.SourceControl.Models; +using Xunit; + +namespace OneWare.SourceControl.UnitTests; + +public sealed class GitOperationsTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "OneWare.Git.Tests", Guid.NewGuid().ToString("N")); + private readonly Repository _repository; + private readonly Signature _signature = new("Git Tests", "git-tests@example.invalid", DateTimeOffset.Now); + + public GitOperationsTests() + { + Repository.Init(_directory); + _repository = new Repository(_directory); + _repository.Refs.UpdateTarget("HEAD", "refs/heads/test-main"); + _repository.Config.Set("core.autocrlf", false); + _repository.Config.Set("commit.gpgsign", false); + } + + [Fact] + public void StagedCommitPreservesUnstagedEditsAndUntrackedFiles() + { + CommitFile("file.txt", "base\n"); + Write("file.txt", "staged\n"); + Commands.Stage(_repository, "file.txt"); + Write("file.txt", "unstaged\n"); + Write("other.txt", "untracked\n"); + + var commit = GitOperations.Commit(_repository, "staged only", _signature, stagedOnly: true); + + Assert.Equal("staged\n", ((Blob)commit["file.txt"].Target).GetContentText()); + Assert.Null(commit["other.txt"]); + Assert.Equal("unstaged\n", File.ReadAllText(Path.Combine(_directory, "file.txt"))); + Assert.True(_repository.RetrieveStatus("file.txt").HasFlag(FileStatus.ModifiedInWorkdir)); + Assert.Equal(FileStatus.NewInWorkdir, _repository.RetrieveStatus("other.txt")); + } + + [Fact] + public void CommitAllIncludesChangesButHonorsGitignore() + { + Write(".gitignore", "ignored.txt\n"); + Write("tracked.txt", "included\n"); + Write("ignored.txt", "not included\n"); + + var commit = GitOperations.Commit(_repository, "initial", _signature, stagedOnly: false); + + Assert.NotNull(commit["tracked.txt"]); + Assert.Null(commit["ignored.txt"]); + Assert.Equal(FileStatus.Ignored, _repository.RetrieveStatus("ignored.txt")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\r\n\t")] + public void InvalidCommitMessageDoesNotStageFiles(string message) + { + Write("file.txt", "untracked\n"); + + Assert.Throws(() => GitOperations.Commit(_repository, message, _signature, stagedOnly: false)); + + Assert.Empty(_repository.Index); + Assert.Null(_repository.Head.Tip); + } + + [Fact] + public void CommitAllDoesNotSilentlyResolveConflicts() + { + var initial = CommitFile("file.txt", "base\n"); + var mainBranch = _repository.Head.FriendlyName; + var incoming = _repository.CreateBranch("incoming", initial); + Commands.Checkout(_repository, incoming); + CommitFile("file.txt", "incoming\n"); + Commands.Checkout(_repository, mainBranch); + CommitFile("file.txt", "current\n"); + var result = _repository.Merge(_repository.Branches["incoming"], _signature); + Assert.Equal(MergeStatus.Conflicts, result.Status); + var tip = _repository.Head.Tip.Id; + + Assert.Throws(() => GitOperations.Commit(_repository, "unresolved", _signature, stagedOnly: false)); + + Assert.NotEmpty(_repository.Index.Conflicts); + Assert.Equal(tip, _repository.Head.Tip.Id); + } + + [Fact] + public void DiscardRestoresIndexWithoutLosingStagedChanges() + { + CommitFile("file.txt", "base\n"); + Write("file.txt", "staged\n"); + Commands.Stage(_repository, "file.txt"); + var stagedBlob = _repository.Index["file.txt"].Id; + Write("file.txt", "unstaged\n"); + Write("unrelated.txt", "keep me\n"); + var tip = _repository.Head.Tip.Id; + + GitOperations.DiscardWorkingTreeFile(_repository, "file.txt"); + + Assert.Equal("staged\n", File.ReadAllText(Path.Combine(_directory, "file.txt"))); + Assert.Equal(stagedBlob, _repository.Index["file.txt"].Id); + Assert.Equal(FileStatus.ModifiedInIndex, _repository.RetrieveStatus("file.txt")); + Assert.Equal(tip, _repository.Head.Tip.Id); + Assert.True(File.Exists(Path.Combine(_directory, "unrelated.txt"))); + } + + [Fact] + public void DiscardRestoresDeletedWorkingFile() + { + CommitFile("file.txt", "base\n"); + File.Delete(Path.Combine(_directory, "file.txt")); + + GitOperations.DiscardWorkingTreeFile(_repository, "file.txt"); + + Assert.Equal("base\n", File.ReadAllText(Path.Combine(_directory, "file.txt"))); + Assert.Equal(FileStatus.Unaltered, _repository.RetrieveStatus("file.txt")); + } + + [Fact] + public void DiscardWorksBeforeFirstCommit() + { + Write("file.txt", "staged\n"); + Commands.Stage(_repository, "file.txt"); + Write("file.txt", "unstaged\n"); + + GitOperations.DiscardWorkingTreeFile(_repository, "file.txt"); + + Assert.Equal("staged\n", File.ReadAllText(Path.Combine(_directory, "file.txt"))); + Assert.Equal(FileStatus.NewInIndex, _repository.RetrieveStatus("file.txt")); + Assert.Null(_repository.Head.Tip); + } + + [Fact] + public void DiscardRefusesUntrackedFileWithoutDeletingIt() + { + Write("file.txt", "keep\n"); + + Assert.Throws(() => GitOperations.DiscardWorkingTreeFile(_repository, "file.txt")); + + Assert.Equal("keep\n", File.ReadAllText(Path.Combine(_directory, "file.txt"))); + } + + [Fact] + public void StagedAndUnstagedDiffsUseDifferentBaselines() + { + CommitFile("folder/file.txt", "base\n"); + Write("folder/file.txt", "staged\n"); + Commands.Stage(_repository, "folder/file.txt"); + Write("folder/file.txt", "unstaged\n"); + + using var staged = GitOperations.GetPatch(_repository, Path.Combine(_directory, "folder", "file.txt"), 3, staged: true); + using var unstaged = GitOperations.GetPatch(_repository, "folder/file.txt", 3, staged: false); + + Assert.Contains("-base\n", staged.Content); + Assert.Contains("+staged\n", staged.Content); + Assert.DoesNotContain("+unstaged\n", staged.Content); + Assert.Contains("-staged\n", unstaged.Content); + Assert.Contains("+unstaged\n", unstaged.Content); + } + + [Fact] + public void StagedDiffSupportsInitialCommit() + { + Write("file.txt", "initial\n"); + Commands.Stage(_repository, "file.txt"); + + using var patch = GitOperations.GetPatch(_repository, "file.txt", 3, staged: true); + + Assert.Contains("+initial\n", patch.Content); + } + + [Fact] + public void WorkingTreeDiffIncludesUntrackedFiles() + { + Write("file.txt", "untracked\n"); + + using var patch = GitOperations.GetPatch(_repository, "file.txt", 3, staged: false); + + Assert.Contains("+untracked\n", patch.Content); + } + + [Theory] + [InlineData("feature/nested/name")] + [InlineData("main")] + public void RemoteCheckoutPreservesFullBranchNameAndTracking(string name) + { + var commit = CommitFile("file.txt", "base\n"); + _repository.Network.Remotes.Add("upstream", Path.Combine(_directory, "remote.git")); + _repository.Refs.Add($"refs/remotes/upstream/{name}", commit.Id); + var remote = _repository.Branches[$"upstream/{name}"]; + + Assert.Equal(name, GitOperations.GetRemoteBranchName(remote)); + var branch = GitOperations.CheckoutBranch(_repository, remote); + + Assert.Equal(name, branch.FriendlyName); + Assert.Equal(remote.CanonicalName, branch.TrackedBranch.CanonicalName); + Assert.Equal(branch.CanonicalName, _repository.Head.CanonicalName); + } + + [Fact] + public void RemoteCheckoutDoesNotHijackUnrelatedLocalBranch() + { + var commit = CommitFile("file.txt", "base\n"); + _repository.CreateBranch("feature/nested", commit); + _repository.Network.Remotes.Add("origin", Path.Combine(_directory, "remote.git")); + _repository.Refs.Add("refs/remotes/origin/feature/nested", commit.Id); + var head = _repository.Head.CanonicalName; + + Assert.Throws(() => GitOperations.CheckoutBranch(_repository, _repository.Branches["origin/feature/nested"])); + + Assert.Equal(head, _repository.Head.CanonicalName); + Assert.False(_repository.Branches["feature/nested"].IsTracking); + } + + [Fact] + public void RejectsPathsOutsideRepository() + { + Assert.Throws(() => GitOperations.GetRelativePath(_repository, "../outside.txt")); + Assert.Throws(() => GitOperations.GetRelativePath(_repository, Path.Combine(Path.GetTempPath(), "outside.txt"))); + } + + [Fact] + public void StatusReplacementNotifiesBindings() + { + Write("file.txt", "new\n"); + var status = _repository.RetrieveStatus().Single(); + var model = new SourceControlFileModel(Path.Combine(_directory, "file.txt"), status); + var properties = new List(); + model.PropertyChanged += (_, args) => properties.Add(args.PropertyName); + Commands.Stage(_repository, "file.txt"); + + model.Status = _repository.RetrieveStatus().Single(); + + Assert.Contains(nameof(SourceControlFileModel.Status), properties); + Assert.Equal(FileStatus.NewInIndex, model.Status.State); + } + + [Fact] + public void PublishNestedBranchSetsUpstreamAfterSuccessfulPush() + { + CommitFile("file.txt", "base\n"); + Commands.Checkout(_repository, _repository.CreateBranch("feature/nested/name")); + var remotePath = Path.Combine(_directory, "remote.git"); + Repository.Init(remotePath, isBare: true); + _repository.Network.Remotes.Add("upstream", remotePath); + + var published = GitOperations.PublishBranch(_repository, "upstream", new PushOptions()); + + using var remote = new Repository(remotePath); + Assert.Equal(_repository.Head.Tip.Id, remote.Branches["feature/nested/name"].Tip.Id); + Assert.Equal("upstream", published.RemoteName); + Assert.Equal("refs/remotes/upstream/feature/nested/name", published.TrackedBranch.CanonicalName); + } + + [Fact] + public void FailedPublishDoesNotSetUpstream() + { + CommitFile("file.txt", "base\n"); + _repository.Network.Remotes.Add("origin", new Uri(Path.Combine(_directory, "missing.git")).AbsoluteUri); + + Assert.ThrowsAny(() => GitOperations.PublishBranch(_repository, "origin", new PushOptions())); + + Assert.False(_repository.Head.IsTracking); + Assert.Null(_repository.Config.Get("branch.test-main.remote")); + Assert.Null(_repository.Config.Get("branch.test-main.merge")); + } + + [Fact] + public void RemoteDeletionPreservesFullNestedName() + { + CommitFile("file.txt", "base\n"); + Commands.Checkout(_repository, _repository.CreateBranch("feature/nested/name")); + var remotePath = Path.Combine(_directory, "remote.git"); + Repository.Init(remotePath, isBare: true); + _repository.Network.Remotes.Add("upstream", remotePath); + GitOperations.PublishBranch(_repository, "upstream", new PushOptions()); + var branch = _repository.Branches["upstream/feature/nested/name"]; + + GitOperations.DeleteRemoteBranch(_repository, branch, new PushOptions()); + + using var remote = new Repository(remotePath); + Assert.Null(remote.Branches["feature/nested/name"]); + Assert.Null(_repository.Branches["upstream/feature/nested/name"]); + Assert.NotNull(_repository.Branches["feature/nested/name"]); + } + + [Fact] + public void FailedRemoteDeletionKeepsLocalTrackingRef() + { + var commit = CommitFile("file.txt", "base\n"); + _repository.Network.Remotes.Add("origin", new Uri(Path.Combine(_directory, "missing.git")).AbsoluteUri); + _repository.Refs.Add("refs/remotes/origin/feature/nested", commit.Id); + var branch = _repository.Branches["origin/feature/nested"]; + + Assert.ThrowsAny(() => GitOperations.DeleteRemoteBranch(_repository, branch, new PushOptions())); + + Assert.Equal(commit.Id, _repository.Branches["origin/feature/nested"].Tip.Id); + } + + [Theory] + [InlineData("https://github.com/team/repository.git", "repository")] + [InlineData("https://github.com/team/repository.git/", "repository")] + [InlineData("https://github.com/team/repository.git?query=value", "repository")] + [InlineData("git@github.com:team/repository.git", "repository")] + [InlineData("ssh://git@example.com/team/repository.git", "repository")] + [InlineData("https://example.com/team/repository.name", "repository.name")] + [InlineData("https://example.com/team/repository.name.git", "repository.name")] + public void CloneFolderNamesHandleCommonUrls(string url, string expected) + { + Assert.Equal(expected, GitOperations.GetCloneDirectoryName(url)); + } + + [Theory] + [InlineData(" ")] + [InlineData("https://example.com/")] + [InlineData("..")] + [InlineData(".")] + public void CloneRejectsMissingFolderName(string url) + { + Assert.Throws(() => GitOperations.GetCloneDirectoryName(url)); + } + + [Theory] + [InlineData(FileStatus.ModifiedInIndex | FileStatus.ModifiedInWorkdir, "M")] + [InlineData(FileStatus.NewInIndex | FileStatus.DeletedFromWorkdir, "D")] + [InlineData(FileStatus.RenamedInIndex | FileStatus.ModifiedInWorkdir, "R")] + [InlineData(FileStatus.Conflicted | FileStatus.ModifiedInWorkdir, "U")] + [InlineData(FileStatus.NewInWorkdir, "+")] + public void CombinedStatusFlagsHaveUsefulLabels(FileStatus status, string label) + { + var converter = new ChangeStatusCharConverter(); + Assert.Equal(label, converter.Convert(status, typeof(string), null, System.Globalization.CultureInfo.InvariantCulture)); + } + + [Fact] + public void HardResetKeepsUntrackedFiles() + { + CommitFile("file.txt", "base\n"); + Write("file.txt", "changed\n"); + Write("untracked.txt", "keep\n"); + + GitOperations.ResetTrackedChanges(_repository, ResetMode.Hard); + + Assert.Equal("base\n", File.ReadAllText(Path.Combine(_directory, "file.txt"))); + Assert.Equal("keep\n", File.ReadAllText(Path.Combine(_directory, "untracked.txt"))); + } + + [Fact] + public void HardResetRefusesToOverwriteUntrackedFileAfterStagedDeletion() + { + CommitFile("file.txt", "base\n"); + _repository.Index.Remove("file.txt"); + _repository.Index.Write(); + Write("file.txt", "untracked replacement\n"); + + Assert.Throws(() => GitOperations.ResetTrackedChanges(_repository, ResetMode.Hard)); + + Assert.Equal("untracked replacement\n", File.ReadAllText(Path.Combine(_directory, "file.txt"))); + Assert.Null(_repository.Index["file.txt"]); + } + + private Commit CommitFile(string path, string content) + { + Write(path, content); + Commands.Stage(_repository, path); + return _repository.Commit("test commit", _signature, _signature); + } + + private void Write(string path, string content) + { + var fullPath = Path.Combine(_directory, path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } + + public void Dispose() + { + _repository.Dispose(); + foreach (var file in Directory.EnumerateFiles(_directory, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(_directory, recursive: true); + } +} \ No newline at end of file diff --git a/tests/OneWare.SourceControl.UnitTests/OneWare.SourceControl.UnitTests.csproj b/tests/OneWare.SourceControl.UnitTests/OneWare.SourceControl.UnitTests.csproj new file mode 100644 index 000000000..17529bb77 --- /dev/null +++ b/tests/OneWare.SourceControl.UnitTests/OneWare.SourceControl.UnitTests.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + false + true + enable + enable + + + + + \ No newline at end of file