Skip to content

Commit

Permalink
Use installed Git command line for fetching repo status (#3509)
Browse files Browse the repository at this point in the history
  • Loading branch information
DefaultRyan authored Jul 29, 2024
1 parent b956d2d commit ee89ce3
Show file tree
Hide file tree
Showing 2 changed files with 282 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public void GetProperties()
"System.VersionControl.CurrentFolderStatus",
};

var relativePath = "a\\a1";
var relativePath = Path.Join("a", "a1");
GitLocalRepository repo = new GitLocalRepository(RepoPath);
var result = repo.GetProperties(properties, relativePath);
Assert.IsNotNull(result);
Expand All @@ -102,4 +102,47 @@ public void GetProperties()
Assert.AreEqual(result["System.VersionControl.LastChangeID"], "d0114ab8ac326bab30e3a657a0397578c5a1af88");
Assert.AreEqual(result["System.VersionControl.CurrentFolderStatus"], "Branch: master ≡ | +0 ~0 -0 | +0 ~0 -0");
}

[TestMethod]
public void GitStatus()
{
const string repoStatusProperty = "System.VersionControl.CurrentFolderStatus";
var properties = new string[]
{
repoStatusProperty,
};
var localRepo = new GitLocalRepository(RepoPath);
var result = localRepo.GetProperties(properties, ".");
Assert.AreEqual(result[repoStatusProperty], "Branch: master ≡ | +0 ~0 -0 | +0 ~0 -0");

// Add a file
var newFileName = "newfile.txt";
var newFilePath = Path.Join(RepoPath, newFileName);
File.WriteAllText(newFilePath, "Initial content");
result = localRepo.GetProperties(properties, ".");
Assert.AreEqual(result[repoStatusProperty], "Branch: master ≡ | +0 ~0 -0 | +1 ~0 -0");

// Stage that add
var modifiedRepo = new Repository(RepoPath);
modifiedRepo.Index.Add(newFileName);
modifiedRepo.Index.Write();
result = localRepo.GetProperties(properties, ".");
Assert.AreEqual(result[repoStatusProperty], "Branch: master ≡ | +1 ~0 -0 | +0 ~0 -0");

// Re-modify the staged file
File.WriteAllText(newFilePath, "New content");
result = localRepo.GetProperties(properties, ".");
Assert.AreEqual(result[repoStatusProperty], "Branch: master ≡ | +1 ~0 -0 | +0 ~1 -0");

// Delete the file, the index still shows the add
File.Delete(newFilePath);
result = localRepo.GetProperties(properties, ".");
Assert.AreEqual(result[repoStatusProperty], "Branch: master ≡ | +1 ~0 -0 | +0 ~0 -1");

// Remove from index, back to clean state
modifiedRepo.Index.Remove(newFileName);
modifiedRepo.Index.Write();
result = localRepo.GetProperties(properties, ".");
Assert.AreEqual(result[repoStatusProperty], "Branch: master ≡ | +0 ~0 -0 | +0 ~0 -0");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Management.Automation;
using LibGit2Sharp;
using Microsoft.Windows.DevHome.SDK;
Expand All @@ -22,10 +23,14 @@ internal sealed class RepositoryWrapper : IDisposable

private bool _disposedValue;

private GitDetect _gitDetect = new();
private bool _gitInstalled;

public RepositoryWrapper(string rootFolder)
{
_repo = new Repository(rootFolder);
_workingDirectory = _repo.Info.WorkingDirectory;
_gitInstalled = _gitDetect.DetectGit();
}

public IEnumerable<Commit> GetCommits()
Expand Down Expand Up @@ -65,15 +70,244 @@ public IEnumerable<Commit> GetCommits()
return _commits;
}

[DebuggerDisplay("{DebuggerDisplay,nq}")]
internal sealed class GitStatusEntry
{
public GitStatusEntry(string path, FileStatus status, string? renameOldPath = null)
{
Path = path;
Status = status;
RenameOldPath = renameOldPath;
}

public string Path { get; set; }

public FileStatus Status { get; set; }

public string? RenameOldPath { get; set; }

public string? RenameNewPath { get; set; }

private string DebuggerDisplay
{
get
{
if (Status.HasFlag(FileStatus.RenamedInIndex) || Status.HasFlag(FileStatus.RenamedInWorkdir))
{
return string.Format(CultureInfo.InvariantCulture, "{0}: {1} -> {2}", Status, RenameOldPath, Path);
}

return string.Format(CultureInfo.InvariantCulture, "{0}: {1}", Status, Path);
}
}
}

internal sealed class GitRepositoryStatus
{
private readonly Dictionary<string, GitStatusEntry> _entries = new();
private readonly List<GitStatusEntry> _added = new();
private readonly List<GitStatusEntry> _staged = new();
private readonly List<GitStatusEntry> _removed = new();
private readonly List<GitStatusEntry> _untracked = new();
private readonly List<GitStatusEntry> _modified = new();
private readonly List<GitStatusEntry> _missing = new();
private readonly List<GitStatusEntry> _ignored = new();
private readonly List<GitStatusEntry> _renamedInIndex = new();
private readonly List<GitStatusEntry> _renamedInWorkDir = new();
private readonly List<GitStatusEntry> _conflicted = new();

public GitRepositoryStatus()
{
}

public void Add(string path, GitStatusEntry status)
{
_entries.Add(path, status);
if (status.Status.HasFlag(FileStatus.NewInIndex))
{
_added.Add(status);
}

if (status.Status.HasFlag(FileStatus.ModifiedInIndex))
{
_staged.Add(status);
}

if (status.Status.HasFlag(FileStatus.DeletedFromIndex))
{
_removed.Add(status);
}

if (status.Status.HasFlag(FileStatus.NewInWorkdir))
{
_untracked.Add(status);
}

if (status.Status.HasFlag(FileStatus.ModifiedInWorkdir))
{
_modified.Add(status);
}

if (status.Status.HasFlag(FileStatus.DeletedFromWorkdir))
{
_missing.Add(status);
}

if (status.Status.HasFlag(FileStatus.RenamedInIndex))
{
_renamedInIndex.Add(status);
}

if (status.Status.HasFlag(FileStatus.RenamedInWorkdir))
{
_renamedInWorkDir.Add(status);
}

if (status.Status.HasFlag(FileStatus.Conflicted))
{
_conflicted.Add(status);
}
}

public Dictionary<string, GitStatusEntry> Entries => _entries;

public List<GitStatusEntry> Added => _added;

public List<GitStatusEntry> Staged => _staged;

public List<GitStatusEntry> Removed => _removed;

public List<GitStatusEntry> Untracked => _untracked;

public List<GitStatusEntry> Modified => _modified;

public List<GitStatusEntry> Missing => _missing;

public List<GitStatusEntry> RenamedInIndex => _renamedInIndex;

public List<GitStatusEntry> RenamedInWorkDir => _renamedInWorkDir;

public List<GitStatusEntry> Conflicted => _conflicted;
}

public string GetRepoStatus()
{
RepositoryStatus repositoryStatus;
var repoStatus = new GitRepositoryStatus();

if (_gitInstalled)
{
// Options fully explained at https://git-scm.com/docs/git-status
// --no-optional-locks : Since this we are essentially running in the background, don't take any optional git locks
// that could interfere with the user's work. This means calling "status" won't auto-update the
// index to make future "status" calls faster, but it's better to be unintrusive.
// --porcelain=v2 : The v2 gives us nice detailed entries that help us separate ordinary changes from renames, conflicts, and untracked
// Disclaimer: I'm not sure how far back porcelain=v2 is supported, but I'm pretty sure it's at least 3-4 years.
// There could be old Git installations that predate it.
// -z : Terminate filenames and entries with NUL instead of space/LF. This helps us deal with filenames containing spaces.
var result = GitExecute.ExecuteGitCommand(_gitDetect.GitConfiguration.ReadInstallPath(), _workingDirectory, "--no-optional-locks status --porcelain=v2 -z");
if (result.Status == ProviderOperationStatus.Success && result.Output != null)
{
var parts = result.Output.Split('\0', StringSplitOptions.RemoveEmptyEntries);
for (var i = 0; i < parts.Length; ++i)
{
var line = parts[i];
if (line.StartsWith("1 ", StringComparison.Ordinal))
{
// For porcelain=v2, "ordinary" entries have the following format:
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
// For now, we only care about the <XY> and <path> fields.
var pieces = line.Split(' ', 9);
var fileStatusString = pieces[1];
var filePath = pieces[8];
FileStatus statusEntry = FileStatus.Unaltered;
switch (fileStatusString[0])
{
case 'M':
statusEntry |= FileStatus.ModifiedInIndex;
break;

case 'T':
statusEntry |= FileStatus.TypeChangeInIndex;
break;

case 'A':
statusEntry |= FileStatus.NewInIndex;
break;

case 'D':
statusEntry |= FileStatus.DeletedFromIndex;
break;
}

switch (fileStatusString[1])
{
case 'M':
statusEntry |= FileStatus.ModifiedInWorkdir;
break;

case 'T':
statusEntry |= FileStatus.TypeChangeInWorkdir;
break;

case 'A':
statusEntry |= FileStatus.NewInWorkdir;
break;

case 'D':
statusEntry |= FileStatus.DeletedFromWorkdir;
break;
}

repoStatus.Add(filePath, new GitStatusEntry(filePath, statusEntry));
}
else if (line.StartsWith("2 ", StringComparison.Ordinal))
{
// For porcelain=v2, "rename" entries have the following format:
// 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path><sep><origPath>
// For now, we only care about the <XY>, <path>, and <origPath> fields.
var pieces = line.Split(' ', 9);
var fileStatusString = pieces[1];
var newPath = pieces[8];
var oldPath = parts[++i];
FileStatus statusEntry = FileStatus.Unaltered;
if (fileStatusString[0] == 'R')
{
statusEntry |= FileStatus.RenamedInIndex;
}

if (fileStatusString[1] == 'R')
{
statusEntry |= FileStatus.RenamedInWorkdir;
}

repoStatus.Add(newPath, new GitStatusEntry(newPath, statusEntry, oldPath));
}
else if (line.StartsWith("u ", StringComparison.Ordinal))
{
// For porcelain=v2, "unmerged" entries have the following format:
// u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
// For now, we only care about the <path>. (We only say that the file has a conflict, not the details)
var pieces = line.Split(' ', 11);
var filePath = pieces[10];
repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.Conflicted));
}
else if (line.StartsWith("? ", StringComparison.Ordinal))
{
// For porcelain=v2, "untracked" entries have the following format:
// ? <path>
// For now, we only care about the <path>.
var filePath = line.Substring(2);
repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.NewInWorkdir));
}
}
}
}

string branchName;
var branchStatus = string.Empty;
try
{
_repoLock.EnterWriteLock();
repositoryStatus = _repo.RetrieveStatus();
branchName = _repo.Info.IsHeadDetached ?
"Detached: " + _repo.Head.Tip.Sha[..7] :
"Branch: " + _repo.Head.FriendlyName;
Expand Down Expand Up @@ -104,8 +338,8 @@ public string GetRepoStatus()
_repoLock.ExitWriteLock();
}

var fileStatus = $"| +{repositoryStatus.Added.Count()} ~{repositoryStatus.Staged.Count()} -{repositoryStatus.Removed.Count()} | +{repositoryStatus.Untracked.Count()} ~{repositoryStatus.Modified.Count()} -{repositoryStatus.Missing.Count()}";
var conflicted = repositoryStatus.Where(x => x.State.HasFlag(FileStatus.Conflicted)).Count();
var fileStatus = $"| +{repoStatus.Added.Count} ~{repoStatus.Staged.Count + repoStatus.RenamedInIndex.Count} -{repoStatus.Removed.Count} | +{repoStatus.Untracked.Count} ~{repoStatus.Modified.Count + repoStatus.RenamedInWorkDir.Count} -{repoStatus.Missing.Count}";
var conflicted = repoStatus.Conflicted.Count;

if (conflicted > 0)
{
Expand Down

0 comments on commit ee89ce3

Please sign in to comment.