using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { public class WorkingCopy : ObservableObject, IDisposable { public Repository Repository { get => _repo; } public bool IncludeUntracked { get => _repo.IncludeUntracked; set { if (_repo.IncludeUntracked != value) { _repo.IncludeUntracked = value; OnPropertyChanged(); } } } public bool HasRemotes { get => _hasRemotes; set => SetProperty(ref _hasRemotes, value); } public bool HasUnsolvedConflicts { get => _hasUnsolvedConflicts; set => SetProperty(ref _hasUnsolvedConflicts, value); } public InProgressContext InProgressContext { get => _inProgressContext; private set => SetProperty(ref _inProgressContext, value); } public bool IsStaging { get => _isStaging; private set => SetProperty(ref _isStaging, value); } public bool IsUnstaging { get => _isUnstaging; private set => SetProperty(ref _isUnstaging, value); } public bool IsCommitting { get => _isCommitting; private set => SetProperty(ref _isCommitting, value); } public bool EnableSignOff { get => _repo.Settings.EnableSignOffForCommit; set => _repo.Settings.EnableSignOffForCommit = value; } public bool NoVerifyOnCommit { get => _repo.Settings.NoVerifyOnCommit; set => _repo.Settings.NoVerifyOnCommit = value; } public bool UseAmend { get => _useAmend; set { if (SetProperty(ref _useAmend, value)) { if (value) { var currentBranch = _repo.CurrentBranch; if (currentBranch == null) { App.RaiseException(_repo.FullPath, "No commits to amend!!!"); _useAmend = false; OnPropertyChanged(); return; } CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, currentBranch.Head).GetResult(); } else { CommitMessage = string.Empty; ResetAuthor = false; } Staged = GetStagedChanges(); VisibleStaged = GetVisibleChanges(_staged); SelectedStaged = []; } } } public bool ResetAuthor { get => _resetAuthor; set => SetProperty(ref _resetAuthor, value); } public string Filter { get => _filter; set { if (SetProperty(ref _filter, value)) { if (_isLoadingData) return; VisibleUnstaged = GetVisibleChanges(_unstaged); VisibleStaged = GetVisibleChanges(_staged); SelectedUnstaged = []; } } } public List Unstaged { get => _unstaged; private set => SetProperty(ref _unstaged, value); } public List VisibleUnstaged { get => _visibleUnstaged; private set => SetProperty(ref _visibleUnstaged, value); } public List Staged { get => _staged; private set => SetProperty(ref _staged, value); } public List VisibleStaged { get => _visibleStaged; private set => SetProperty(ref _visibleStaged, value); } public List SelectedUnstaged { get => _selectedUnstaged; set { if (SetProperty(ref _selectedUnstaged, value)) { if (value == null || value.Count == 0) { if (_selectedStaged == null || _selectedStaged.Count == 0) SetDetail(null, true); } else { if (_selectedStaged is { Count: > 0 }) SelectedStaged = []; if (value.Count == 1) SetDetail(value[0], true); else SetDetail(null, true); } } } } public List SelectedStaged { get => _selectedStaged; set { if (SetProperty(ref _selectedStaged, value)) { if (value == null || value.Count == 0) { if (_selectedUnstaged == null || _selectedUnstaged.Count == 0) SetDetail(null, false); } else { if (_selectedUnstaged is { Count: > 0 }) SelectedUnstaged = []; if (value.Count == 1) SetDetail(value[0], false); else SetDetail(null, false); } } } } public object DetailContext { get => _detailContext; private set => SetProperty(ref _detailContext, value); } public string CommitMessage { get => _commitMessage; set => SetProperty(ref _commitMessage, value); } public WorkingCopy(Repository repo) { _repo = repo; } public void Dispose() { _repo = null; _inProgressContext = null; _selectedUnstaged.Clear(); OnPropertyChanged(nameof(SelectedUnstaged)); _selectedStaged.Clear(); OnPropertyChanged(nameof(SelectedStaged)); _visibleUnstaged.Clear(); OnPropertyChanged(nameof(VisibleUnstaged)); _visibleStaged.Clear(); OnPropertyChanged(nameof(VisibleStaged)); _unstaged.Clear(); OnPropertyChanged(nameof(Unstaged)); _staged.Clear(); OnPropertyChanged(nameof(Staged)); _detailContext = null; _commitMessage = string.Empty; } public void SetData(List changes, CancellationToken cancellationToken) { if (!IsChanged(_cached, changes)) { // Just force refresh selected changes. Dispatcher.UIThread.Invoke(() => { if (cancellationToken.IsCancellationRequested) return; HasUnsolvedConflicts = _cached.Find(x => x.IsConflicted) != null; UpdateDetail(); UpdateInProgressState(); }); return; } _cached = changes; var lastSelectedUnstaged = new HashSet(); var lastSelectedStaged = new HashSet(); if (_selectedUnstaged is { Count: > 0 }) { foreach (var c in _selectedUnstaged) lastSelectedUnstaged.Add(c.Path); } else if (_selectedStaged is { Count: > 0 }) { foreach (var c in _selectedStaged) lastSelectedStaged.Add(c.Path); } var unstaged = new List(); var hasConflict = false; foreach (var c in changes) { if (c.WorkTree != Models.ChangeState.None) { unstaged.Add(c); hasConflict |= c.IsConflicted; } } var visibleUnstaged = GetVisibleChanges(unstaged); var selectedUnstaged = new List(); foreach (var c in visibleUnstaged) { if (lastSelectedUnstaged.Contains(c.Path)) selectedUnstaged.Add(c); } var staged = GetStagedChanges(); var visibleStaged = GetVisibleChanges(staged); var selectedStaged = new List(); foreach (var c in visibleStaged) { if (lastSelectedStaged.Contains(c.Path)) selectedStaged.Add(c); } Dispatcher.UIThread.Invoke(() => { if (cancellationToken.IsCancellationRequested) return; _isLoadingData = true; HasUnsolvedConflicts = hasConflict; VisibleUnstaged = visibleUnstaged; VisibleStaged = visibleStaged; Unstaged = unstaged; Staged = staged; SelectedUnstaged = selectedUnstaged; SelectedStaged = selectedStaged; _isLoadingData = false; UpdateDetail(); UpdateInProgressState(); }); } public async Task StageChangesAsync(List changes, Models.Change next) { var canStaged = await GetCanStageChangesAsync(changes); var count = canStaged.Count; if (count == 0) return; IsStaging = true; _selectedUnstaged = next != null ? [next] : []; using var lockWatcher = _repo.LockWatcher(); var log = _repo.CreateLog("Stage"); if (count == _unstaged.Count) { await new Commands.Add(_repo.FullPath, _repo.IncludeUntracked).Use(log).ExecAsync(); } else { var pathSpecFile = Path.GetTempFileName(); await using (var writer = new StreamWriter(pathSpecFile)) { foreach (var c in canStaged) await writer.WriteLineAsync(c.Path); } await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); File.Delete(pathSpecFile); } log.Complete(); _repo.MarkWorkingCopyDirtyManually(); IsStaging = false; } public async Task UnstageChangesAsync(List changes, Models.Change next) { var count = changes.Count; if (count == 0) return; IsUnstaging = true; _selectedStaged = next != null ? [next] : []; using var lockWatcher = _repo.LockWatcher(); var log = _repo.CreateLog("Unstage"); if (_useAmend) { log.AppendLine("$ git update-index --index-info "); await new Commands.UnstageChangesForAmend(_repo.FullPath, changes).ExecAsync(); } else { var pathSpecFile = Path.GetTempFileName(); await using (var writer = new StreamWriter(pathSpecFile)) { foreach (var c in changes) { await writer.WriteLineAsync(c.Path); if (c.Index == Models.ChangeState.Renamed) await writer.WriteLineAsync(c.OriginalPath); } } await new Commands.Restore(_repo.FullPath, pathSpecFile, true).Use(log).ExecAsync(); File.Delete(pathSpecFile); } log.Complete(); _repo.MarkWorkingCopyDirtyManually(); IsUnstaging = false; } public async Task SaveChangesToPatchAsync(List changes, bool isUnstaged, string saveTo) { var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, changes, isUnstaged, saveTo); if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); } public void Discard(List changes) { if (_repo.CanCreatePopup()) _repo.ShowPopup(new Discard(_repo, changes)); } public void ClearFilter() { Filter = string.Empty; } public async Task UseTheirsAsync(List changes) { using var lockWatcher = _repo.LockWatcher(); var files = new List(); var needStage = new List(); var log = _repo.CreateLog("Use Theirs"); foreach (var change in changes) { if (!change.IsConflicted) continue; if (change.ConflictReason is Models.ConflictReason.BothDeleted or Models.ConflictReason.DeletedByThem or Models.ConflictReason.AddedByUs) { var fullpath = Path.Combine(_repo.FullPath, change.Path); if (File.Exists(fullpath)) File.Delete(fullpath); needStage.Add(change.Path); } else { files.Add(change.Path); } } if (files.Count > 0) { var succ = await new Commands.Checkout(_repo.FullPath).Use(log).UseTheirsAsync(files); if (succ) needStage.AddRange(files); } if (needStage.Count > 0) { var pathSpecFile = Path.GetTempFileName(); await File.WriteAllLinesAsync(pathSpecFile, needStage); await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); File.Delete(pathSpecFile); } log.Complete(); _repo.MarkWorkingCopyDirtyManually(); } public async Task UseMineAsync(List changes) { using var lockWatcher = _repo.LockWatcher(); var files = new List(); var needStage = new List(); var log = _repo.CreateLog("Use Mine"); foreach (var change in changes) { if (!change.IsConflicted) continue; if (change.ConflictReason is Models.ConflictReason.BothDeleted or Models.ConflictReason.DeletedByUs or Models.ConflictReason.AddedByThem) { var fullpath = Path.Combine(_repo.FullPath, change.Path); if (File.Exists(fullpath)) File.Delete(fullpath); needStage.Add(change.Path); } else { files.Add(change.Path); } } if (files.Count > 0) { var succ = await new Commands.Checkout(_repo.FullPath).Use(log).UseMineAsync(files); if (succ) needStage.AddRange(files); } if (needStage.Count > 0) { var pathSpecFile = Path.GetTempFileName(); await File.WriteAllLinesAsync(pathSpecFile, needStage); await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); File.Delete(pathSpecFile); } log.Complete(); _repo.MarkWorkingCopyDirtyManually(); } public async Task UseExternalMergeToolAsync(Models.Change change) { return await new Commands.MergeTool(_repo.FullPath, change?.Path).OpenAsync(); } public void UseExternalDiffTool(Models.Change change, bool isUnstaged) { new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(change, isUnstaged)).Open(); } public async Task ContinueMergeAsync() { if (_inProgressContext != null) { using var lockWatcher = _repo.LockWatcher(); IsCommitting = true; var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); if (File.Exists(mergeMsgFile) && !string.IsNullOrWhiteSpace(_commitMessage)) await File.WriteAllTextAsync(mergeMsgFile, _commitMessage); await _inProgressContext.ContinueAsync(); CommitMessage = string.Empty; IsCommitting = false; } else { _repo.MarkWorkingCopyDirtyManually(); } } public async Task SkipMergeAsync() { if (_inProgressContext != null) { using var lockWatcher = _repo.LockWatcher(); IsCommitting = true; await _inProgressContext.SkipAsync(); CommitMessage = string.Empty; IsCommitting = false; } else { _repo.MarkWorkingCopyDirtyManually(); } } public async Task AbortMergeAsync() { if (_inProgressContext != null) { using var lockWatcher = _repo.LockWatcher(); IsCommitting = true; await _inProgressContext.AbortAsync(); CommitMessage = string.Empty; IsCommitting = false; } else { _repo.MarkWorkingCopyDirtyManually(); } } public void ApplyCommitMessageTemplate(Models.CommitTemplate tmpl) { CommitMessage = tmpl.Apply(_repo.CurrentBranch, _staged); } public async Task ClearCommitMessageHistoryAsync() { var sure = await App.AskConfirmAsync(App.Text("WorkingCopy.ClearCommitHistories.Confirm")); if (sure) _repo.Settings.CommitMessages.Clear(); } public async Task CommitAsync(bool autoStage, bool autoPush) { if (string.IsNullOrWhiteSpace(_commitMessage)) return; if (!_repo.CanCreatePopup()) { App.RaiseException(_repo.FullPath, "Repository has an unfinished job! Please wait!"); return; } if (autoStage && HasUnsolvedConflicts) { App.RaiseException(_repo.FullPath, "Repository has unsolved conflict(s). Auto-stage and commit is disabled!"); return; } if (_repo.CurrentBranch is { IsDetachedHead: true }) { var msg = App.Text("WorkingCopy.ConfirmCommitWithDetachedHead"); var sure = await App.AskConfirmAsync(msg); if (!sure) return; } if (!string.IsNullOrEmpty(_filter) && _staged.Count > _visibleStaged.Count) { var msg = App.Text("WorkingCopy.ConfirmCommitWithFilter", _staged.Count, _visibleStaged.Count, _staged.Count - _visibleStaged.Count); var sure = await App.AskConfirmAsync(msg); if (!sure) return; } if (!_useAmend) { if ((!autoStage && _staged.Count == 0) || (autoStage && _cached.Count == 0)) { var rs = await App.AskConfirmEmptyCommitAsync(_cached.Count > 0); if (rs == Models.ConfirmEmptyCommitResult.Cancel) return; if (rs == Models.ConfirmEmptyCommitResult.StageAllAndCommit) autoStage = true; } } using var lockWatcher = _repo.LockWatcher(); IsCommitting = true; _repo.Settings.PushCommitMessage(_commitMessage); var log = _repo.CreateLog("Commit"); var succ = true; if (autoStage && _unstaged.Count > 0) succ = await new Commands.Add(_repo.FullPath, _repo.IncludeUntracked) .Use(log) .ExecAsync() .ConfigureAwait(false); if (succ) succ = await new Commands.Commit(_repo.FullPath, _commitMessage, EnableSignOff, NoVerifyOnCommit, _useAmend, _resetAuthor) .Use(log) .RunAsync() .ConfigureAwait(false); log.Complete(); if (succ) { CommitMessage = string.Empty; UseAmend = false; if (autoPush && _repo.Remotes.Count > 0) { Models.Branch pushBranch = null; if (_repo.CurrentBranch == null) { var currentBranchName = await new Commands.QueryCurrentBranch(_repo.FullPath).GetResultAsync(); pushBranch = new Models.Branch() { Name = currentBranchName }; } if (_repo.CanCreatePopup()) await _repo.ShowAndStartPopupAsync(new Push(_repo, pushBranch)); } } _repo.MarkBranchesDirtyManually(); IsCommitting = false; } private List GetVisibleChanges(List changes) { if (string.IsNullOrEmpty(_filter)) return changes; var visible = new List(); foreach (var c in changes) { if (c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) visible.Add(c); } return visible; } private async Task> GetCanStageChangesAsync(List changes) { if (!HasUnsolvedConflicts) return changes; var outs = new List(); foreach (var c in changes) { if (c.IsConflicted) { var isResolved = c.ConflictReason switch { Models.ConflictReason.BothAdded or Models.ConflictReason.BothModified => await new Commands.IsConflictResolved(_repo.FullPath, c).GetResultAsync(), _ => false, }; if (!isResolved) continue; } outs.Add(c); } return outs; } private List GetStagedChanges() { if (_useAmend) { var head = new Commands.QuerySingleCommit(_repo.FullPath, "HEAD").GetResult(); return new Commands.QueryStagedChangesWithAmend(_repo.FullPath, head.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : $"{head.SHA}^").GetResult(); } var rs = new List(); foreach (var c in _cached) { if (c.Index != Models.ChangeState.None) rs.Add(c); } return rs; } private void UpdateDetail() { if (_selectedUnstaged.Count == 1) SetDetail(_selectedUnstaged[0], true); else if (_selectedStaged.Count == 1) SetDetail(_selectedStaged[0], false); else SetDetail(null, false); } private void UpdateInProgressState() { var oldType = _inProgressContext != null ? _inProgressContext.GetType() : null; if (File.Exists(Path.Combine(_repo.GitDir, "CHERRY_PICK_HEAD"))) InProgressContext = new CherryPickInProgress(_repo); else if (Directory.Exists(Path.Combine(_repo.GitDir, "rebase-merge")) || Directory.Exists(Path.Combine(_repo.GitDir, "rebase-apply"))) InProgressContext = new RebaseInProgress(_repo); else if (File.Exists(Path.Combine(_repo.GitDir, "REVERT_HEAD"))) InProgressContext = new RevertInProgress(_repo); else if (File.Exists(Path.Combine(_repo.GitDir, "MERGE_HEAD"))) InProgressContext = new MergeInProgress(_repo); else InProgressContext = null; if (_inProgressContext != null && _inProgressContext.GetType() == oldType && !string.IsNullOrEmpty(_commitMessage)) return; if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "MERGE_MSG"))) return; if (_inProgressContext is not RebaseInProgress { } rebasing) return; if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "rebase-merge", "message"))) return; CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, rebasing.StoppedAt.SHA).GetResult(); } private bool LoadCommitMessageFromFile(string file) { if (!File.Exists(file)) return false; var msg = File.ReadAllText(file).Trim(); if (string.IsNullOrEmpty(msg)) return false; CommitMessage = msg; return true; } private void SetDetail(Models.Change change, bool isUnstaged) { if (_isLoadingData) return; if (change == null) DetailContext = null; else if (change.IsConflicted) DetailContext = new Conflict(_repo, this, change); else DetailContext = new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged), _detailContext as DiffContext); } private bool IsChanged(List old, List cur) { if (old.Count != cur.Count) return true; for (int idx = 0; idx < old.Count; idx++) { var o = old[idx]; var c = cur[idx]; if (o.Path.Equals(c.Path, StringComparison.Ordinal) || o.Index != c.Index || o.WorkTree != c.WorkTree) return true; } return false; } private Repository _repo = null; private bool _isLoadingData = false; private bool _isStaging = false; private bool _isUnstaging = false; private bool _isCommitting = false; private bool _useAmend = false; private bool _resetAuthor = false; private bool _hasRemotes = false; private List _cached = []; private List _unstaged = []; private List _visibleUnstaged = []; private List _staged = []; private List _visibleStaged = []; private List _selectedUnstaged = []; private List _selectedStaged = []; private object _detailContext = null; private string _filter = string.Empty; private string _commitMessage = string.Empty; private bool _hasUnsolvedConflicts = false; private InProgressContext _inProgressContext = null; } }