Skip to content

Feature: Git Integration Phase 3 #12344

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
May 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Files.App/Data/Items/BranchItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (c) 2023 Files Community
// Licensed under the MIT License. See the LICENSE.

namespace Files.App.Data.Items
{
public record BranchItem(string Name, bool IsRemote);
}
74 changes: 61 additions & 13 deletions src/Files.App/Data/Models/DirectoryPropertiesViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
// Copyright (c) 2023 Files Community
// Licensed under the MIT License. See the LICENSE.

using System.Windows.Input;

namespace Files.App.Data.Models
{
public class DirectoryPropertiesViewModel : ObservableObject
{
public int ActiveBranchIndex { get; private set; }
// The first branch will always be the active one.
public const int ACTIVE_BRANCH_INDEX = 0;

private string? _gitRepositoryPath;

private readonly ObservableCollection<string> _localBranches = new();

private string _DirectoryItemCount;
public string DirectoryItemCount
private readonly ObservableCollection<string> _remoteBranches = new();

private string? _DirectoryItemCount;
public string? DirectoryItemCount
{
get => _DirectoryItemCount;
set => SetProperty(ref _DirectoryItemCount, value);
Expand All @@ -27,29 +36,68 @@ public int SelectedBranchIndex
get => _SelectedBranchIndex;
set
{
if (SetProperty(ref _SelectedBranchIndex, value) && value != -1 && value != ActiveBranchIndex)
if (SetProperty(ref _SelectedBranchIndex, value) &&
value != -1 &&
(value != ACTIVE_BRANCH_INDEX || !_ShowLocals))
{
CheckoutRequested?.Invoke(this, BranchesNames[value]);
}
}
}

public ObservableCollection<string> BranchesNames { get; } = new();
private bool _ShowLocals = true;
public bool ShowLocals
{
get => _ShowLocals;
set
{
if (SetProperty(ref _ShowLocals, value))
{
OnPropertyChanged(nameof(BranchesNames));

if (value)
SelectedBranchIndex = ACTIVE_BRANCH_INDEX;
}
}
}

public ObservableCollection<string> BranchesNames => _ShowLocals
? _localBranches
: _remoteBranches;

public EventHandler<string>? CheckoutRequested;

public void UpdateGitInfo(bool isGitRepository, string activeBranch, string[] branches)
public ICommand NewBranchCommand { get; }

public DirectoryPropertiesViewModel()
{
NewBranchCommand = new AsyncRelayCommand(()
=> GitHelpers.CreateNewBranch(_gitRepositoryPath!, _localBranches[ACTIVE_BRANCH_INDEX]));
}

public void UpdateGitInfo(bool isGitRepository, string? repositoryPath, BranchItem[] branches)
{
GitBranchDisplayName = isGitRepository
? string.Format("Branch".GetLocalizedResource(), activeBranch)
GitBranchDisplayName = isGitRepository && branches.Any()
? string.Format("Branch".GetLocalizedResource(), branches[ACTIVE_BRANCH_INDEX].Name)
: null;

_gitRepositoryPath = repositoryPath;
ShowLocals = true;

if (isGitRepository)
{
BranchesNames.Clear();
foreach (var name in branches)
BranchesNames.Add(name);
_localBranches.Clear();
_remoteBranches.Clear();

foreach (var branch in branches)
{
if (branch.IsRemote)
_remoteBranches.Add(branch.Name);
else
_localBranches.Add(branch.Name);
}

ActiveBranchIndex = BranchesNames.IndexOf(activeBranch);
SelectedBranchIndex = ActiveBranchIndex;
SelectedBranchIndex = ACTIVE_BRANCH_INDEX;
}
}
}
Expand Down
102 changes: 102 additions & 0 deletions src/Files.App/Dialogs/AddBranchDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<!-- Copyright (c) 2023 Files Community. Licensed under the MIT License. See the LICENSE. -->
<ContentDialog
x:Class="Files.App.Dialogs.AddBranchDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="using:Files.App.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Name="AddBranch"
Title="{helpers:ResourceString Name=CreateNewBranch}"
Closing="ContentDialog_Closing"
CornerRadius="{StaticResource OverlayCornerRadius}"
DefaultButton="Primary"
IsPrimaryButtonEnabled="{x:Bind ViewModel.IsBranchValid, Mode=OneWay}"
PrimaryButtonStyle="{StaticResource AccentButtonStyle}"
PrimaryButtonText="{helpers:ResourceString Name=Create}"
RequestedTheme="{x:Bind helpers:ThemeHelper.RootTheme}"
SecondaryButtonText="{helpers:ResourceString Name=Cancel}"
Style="{StaticResource DefaultContentDialogStyle}"
mc:Ignorable="d">

<StackPanel Width="440" Spacing="4">
<!-- Branch Name -->
<Grid
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
ColumnSpacing="8"
CornerRadius="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="{helpers:ResourceString Name=Name}" />
<TextBox
x:Name="BranchNameBox"
Grid.Column="1"
Width="260"
PlaceholderText="{helpers:ResourceString Name=EnterName}"
Text="{x:Bind ViewModel.NewBranchName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Resources>
<TeachingTip
x:Name="InvalidNameWarning"
Title="{helpers:ResourceString Name=InvalidBranchName}"
IsOpen="{x:Bind ViewModel.ShowWarningTip, Mode=OneWay}"
PreferredPlacement="Bottom"
Target="{x:Bind BranchNameBox}" />
</TextBox.Resources>
</TextBox>
</Grid>

<!-- Branch Options -->
<Grid
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
ColumnSpacing="8"
CornerRadius="4"
RowSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>

<!-- Based On -->
<TextBlock
Grid.Row="0"
Grid.Column="0"
VerticalAlignment="Center"
Text="{helpers:ResourceString Name=BasedOn}" />
<ComboBox
x:Name="BranchBox"
Grid.Row="0"
Grid.Column="1"
Width="160"
ItemsSource="{x:Bind ViewModel.Branches}"
SelectedItem="{x:Bind ViewModel.BasedOn, Mode=TwoWay}" />

<!-- Switch To Branch -->
<TextBlock
Grid.Row="1"
Grid.Column="0"
VerticalAlignment="Center"
Text="{helpers:ResourceString Name=SwitchToNewBranch}" />
<ToggleSwitch
Grid.Row="1"
Grid.Column="1"
MinWidth="0"
HorizontalAlignment="Right"
IsOn="{x:Bind ViewModel.Checkout, Mode=TwoWay}" />
</Grid>
</StackPanel>
</ContentDialog>
33 changes: 33 additions & 0 deletions src/Files.App/Dialogs/AddBranchDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2023 Files Community
// Licensed under the MIT License. See the LICENSE.

using Files.App.ViewModels.Dialogs;
using Files.Backend.ViewModels.Dialogs;
using Microsoft.UI.Xaml.Controls;

// The Content Dialog item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238

namespace Files.App.Dialogs
{
public sealed partial class AddBranchDialog : ContentDialog, IDialog<AddBranchDialogViewModel>
{
public AddBranchDialogViewModel ViewModel
{
get => (AddBranchDialogViewModel)DataContext;
set => DataContext = value;
}

public AddBranchDialog()
{
InitializeComponent();
}

public new async Task<DialogResult> ShowAsync() => (DialogResult)await base.ShowAsync();

private void ContentDialog_Closing(ContentDialog _, ContentDialogClosingEventArgs e)
{
InvalidNameWarning.IsOpen = false;
Closing -= ContentDialog_Closing;
}
}
}
96 changes: 85 additions & 11 deletions src/Files.App/Helpers/GitHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@
// Licensed under the MIT License. See the LICENSE.

using Files.App.Filesystem.StorageItems;
using Files.App.ViewModels.Dialogs;
using Files.Backend.Services;
using LibGit2Sharp;
using Microsoft.AppCenter.Analytics;
using System.Text.RegularExpressions;

namespace Files.App.Helpers
{
public static class GitHelpers
internal static class GitHelpers
{
private const string BRANCH_NAME_PATTERN = @"^(?!/)(?!.*//)[^\000-\037\177 ~^:?*[]+(?!.*\.\.)(?!.*@\{)(?!.*\\)(?<!/\.)(?<!\.)(?<!/)(?<!\.lock)$";

private const int END_OF_ORIGIN_PREFIX = 7;

public static string? GetGitRepositoryPath(string? path, string root)
{
if (root.EndsWith('\\'))
Expand All @@ -34,17 +41,18 @@ public static class GitHelpers
}
}

public static string[] GetLocalBranchesNames(string? path)
public static BranchItem[] GetBranchesNames(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !Repository.IsValid(path))
return Array.Empty<string>();
return Array.Empty<BranchItem>();

using var repository = new Repository(path);
return repository.Branches
.Where(b => !b.IsRemote)
.Where(b => !b.IsRemote || b.RemoteName == "origin")
.OrderByDescending(b => b.IsCurrentRepositoryHead)
.ThenBy(b => b.IsRemote)
.ThenByDescending(b => b.Tip.Committer.When)
.Select(b => b.FriendlyName)
.Select(b => new BranchItem(b.FriendlyName, b.IsRemote))
.ToArray();
}

Expand Down Expand Up @@ -79,21 +87,87 @@ public static async Task<bool> Checkout(string? repositoryPath, string? branch)
break;
case GitCheckoutOptions.BringChanges:
case GitCheckoutOptions.StashChanges:
repository.Stashes.Add(repository.Config.BuildSignature(DateTimeOffset.Now));
var signature = repository.Config.BuildSignature(DateTimeOffset.Now);
if (signature is null)
return false;

repository.Stashes.Add(signature);

isBringingChanges = resolveConflictOption is GitCheckoutOptions.BringChanges;
break;
}
}

LibGit2Sharp.Commands.Checkout(repository, checkoutBranch, options);
try
{
if (checkoutBranch.IsRemote)
CheckoutRemoteBranch(repository, checkoutBranch);
else
LibGit2Sharp.Commands.Checkout(repository, checkoutBranch, options);

if (isBringingChanges)
if (isBringingChanges)
{
var lastStashIndex = repository.Stashes.Count() - 1;
repository.Stashes.Pop(lastStashIndex, new StashApplyOptions());
}
return true;
}
catch (Exception)
{
var lastStashIndex = repository.Stashes.Count() - 1;
repository.Stashes.Pop(lastStashIndex, new StashApplyOptions());
return false;
}
return true;
}

public static async Task CreateNewBranch(string repositoryPath, string activeBranch)
{
var viewModel = new AddBranchDialogViewModel(repositoryPath, activeBranch);
var dialog = Ioc.Default.GetRequiredService<IDialogService>().GetDialog(viewModel);

var result = await dialog.TryShowAsync();

if (result != DialogResult.Primary)
return;

using var repository = new Repository(repositoryPath);

if (repository.Head.FriendlyName.Equals(viewModel.NewBranchName) ||
await Checkout(repositoryPath, viewModel.BasedOn))
{
Analytics.TrackEvent($"Triggered git branch");

repository.CreateBranch(viewModel.NewBranchName);

if (viewModel.Checkout)
await Checkout(repositoryPath, viewModel.NewBranchName);
}
}

public static bool ValidateBranchNameForRepository(string branchName, string repositoryPath)
{
if (string.IsNullOrEmpty(branchName) || !Repository.IsValid(repositoryPath))
return false;

var nameValidator = new Regex(BRANCH_NAME_PATTERN);
if (!nameValidator.IsMatch(branchName))
return false;

using var repository = new Repository(repositoryPath);
return !repository.Branches.Any(branch =>
branch.FriendlyName.Equals(branchName, StringComparison.OrdinalIgnoreCase));
}

private static void CheckoutRemoteBranch(Repository repository, Branch branch)
{
var uniqueName = branch.FriendlyName.Substring(END_OF_ORIGIN_PREFIX);

var discriminator = 0;
while (repository.Branches.Any(b => !b.IsRemote && b.FriendlyName == uniqueName))
uniqueName = $"{branch.FriendlyName}_{++discriminator}";

var newBranch = repository.CreateBranch(uniqueName, branch.Tip);
repository.Branches.Update(newBranch, b => b.TrackedBranch = branch.CanonicalName);

LibGit2Sharp.Commands.Checkout(repository, newBranch);
}
}
}
3 changes: 2 additions & 1 deletion src/Files.App/ServicesImplementation/DialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public DialogService()
{ typeof(DecompressArchiveDialogViewModel), () => new DecompressArchiveDialog() },
{ typeof(SettingsDialogViewModel), () => new SettingsDialog() },
{ typeof(CreateShortcutDialogViewModel), () => new CreateShortcutDialog() },
{ typeof(ReorderSidebarItemsDialogViewModel), () => new ReorderSidebarItemsDialog() }
{ typeof(ReorderSidebarItemsDialogViewModel), () => new ReorderSidebarItemsDialog() },
{ typeof(AddBranchDialogViewModel), () => new AddBranchDialog() }
};
}

Expand Down
Loading