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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OneWare.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
<Project Path="tests/OneWare.Dock.HeadlessTests/OneWare.Dock.HeadlessTests.csproj" />
<Project Path="tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj" />
<Project Path="tests/OneWare.PackageManager.UnitTests/OneWare.PackageManager.UnitTests.csproj" />
<Project Path="tests/OneWare.SourceControl.UnitTests/OneWare.SourceControl.UnitTests.csproj" />
<Project Path="tests/OneWare.Studio.Desktop.UnitTests/OneWare.Studio.Desktop.UnitTests.csproj" />
<Project Path="tests/OneWare.Terminal.UnitTests/OneWare.Terminal.UnitTests.csproj" />
<Project Path="tests/OneWare.TestPlugin/OneWare.TestPlugin.csproj" />
Expand Down
38 changes: 38 additions & 0 deletions docs/SourceControl.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
133 changes: 133 additions & 0 deletions src/OneWare.SourceControl/GitOperations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using LibGit2Sharp;

namespace OneWare.SourceControl;

/// <summary>Git operations shared by the UI and repository-level regression tests.</summary>
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<Patch>(repository.Head.Tip?.Tree, DiffTargets.Index, paths,
new ExplicitPathsOptions(), options)
: repository.Diff.Compare<Patch>(paths, true, new ExplicitPathsOptions(), options);
}
}
44 changes: 29 additions & 15 deletions src/OneWare.SourceControl/Models/GitRepositoryModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

namespace OneWare.SourceControl.Models;

public class GitRepositoryModel : ObservableObject
public class GitRepositoryModel : ObservableObject, IDisposable
{
private Branch? _headBranch;

Expand All @@ -27,6 +27,7 @@ public GitRepositoryModel(IProjectRoot project, Repository repository)
{
Project = project;
Repository = repository;
WorkingPath = repository.Info.WorkingDirectory;
}

public IProjectRoot Project { get; private set; }
Expand Down Expand Up @@ -64,23 +65,28 @@ public int PushCommits

public ObservableCollection<MenuItemModel> AvailableBranchesMenu { get; } = new();

public void Refresh(SourceControlViewModel sourceControlViewModel)
public async Task RefreshAsync(SourceControlViewModel sourceControlViewModel)
{
var changes = new List<SourceControlFileModel>();
var stagedChanges = new List<SourceControlFileModel>();
var mergeChanges = new List<SourceControlFileModel>();

var projectExplorerService = ContainerLocator.Container.Resolve<IProjectExplorerService>();

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<MenuItemModel>();

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))
Expand Down Expand Up @@ -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
{
Expand All @@ -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);

Expand All @@ -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) ||
Expand All @@ -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<ILogger>()?.Error(e.Message, e);
return;
}

StagedChanges.Merge(stagedChanges, (a, b) =>
Expand Down Expand Up @@ -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();
}
9 changes: 7 additions & 2 deletions src/OneWare.SourceControl/Models/SourceControlFileModel.cs
Original file line number Diff line number Diff line change
@@ -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)
{
Expand All @@ -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);
}
Loading
Loading