Skip to content
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
18 changes: 18 additions & 0 deletions GVFS/FastFetch/FastFetchVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,24 @@ private int ExecuteWithExitCode()
return ExitFailure;
}

// Check this before any code that reads or writes refs assuming the "files"
// ref storage format runs, since FastFetch can be pointed at an arbitrary
// pre-existing git repository rather than only ones 'gvfs clone' created.
// A genuine config-read failure (as opposed to the key simply being absent,
// which reports the repository as files-format) is treated as fatal here: a
// missing key does not reach this branch, so a read error means the repo's
// config is anomalous and continuing risks operating on an unsupported repo.
if (!RefStorage.TryIsReftableRepo(enlistment.CreateGitProcess(), out bool isReftableRepo, out string refStorageReadError))
{
Console.WriteLine("Could not determine the repository's ref storage format: " + refStorageReadError);
return ExitFailure;
}
else if (isReftableRepo)
{
Console.WriteLine(RefStorage.UnsupportedReftableErrorMessage);
return ExitFailure;
}

string commitish = this.Commit ?? this.Branch;
if (string.IsNullOrWhiteSpace(commitish))
{
Expand Down
40 changes: 38 additions & 2 deletions GVFS/GVFS.Common/Git/GitProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ public class GitProcess : ICredentialStore
private static bool failedToSetEncoding = false;
private static string expireTimeDateString;

/// <summary>
/// First git version that supports 'git init --ref-format' and the reftable ref
/// backend (git 2.45). Earlier git can neither create a reftable repository nor
/// understand the option, so the '--ref-format=files' pin is only added from here on.
/// </summary>
private static readonly GitVersion FirstGitVersionSupportingRefFormat = new GitVersion(2, 45, 0, (string)null);

/// <summary>
/// Lock taken for duration of running executingProcess.
/// </summary>
Expand Down Expand Up @@ -130,13 +137,42 @@ public static string ExpireTimeDateString

public bool LowerPriority { get; set; }

public static Result Init(Enlistment enlistment)
/// <summary>
/// Runs 'git init' for a new enlistment. The caller passes the already-resolved
/// installed git version (the clone flow validates it earlier via
/// GVFSVerb.CheckGitVersion) so this does not shell out for 'git --version' again;
/// pass null when the version is unknown, which conservatively omits the
/// version-gated '--ref-format=files' pin.
/// </summary>
public static Result Init(Enlistment enlistment, GitVersion installedGitVersion)
{
// --object-format=sha1 is pinned explicitly so that GVFS does not silently pick up
// a future git default of sha256. AzDO (the only GVFS-protocol server implementer)
// has no plans to support sha256, and GVFS's own code assumes 20-byte/40-hex-char
// sha1 object IDs throughout, so a sha256 repo would corrupt data or crash.
return enlistment.CreateGitProcess().InvokeGitOutsideEnlistment("init --object-format=sha1 \"" + enlistment.WorkingDirectoryBackingRoot + "\"");
string arguments = "init --object-format=sha1";

// --ref-format=files is pinned for the same reason on the ref storage axis: GVFS
// writes refs directly (e.g. CloneVerb writes .git/packed-refs) rather than through
// git's ref backend, and the reftable backend ignores those writes. Pinning here
// overrides a user's global init.defaultRefFormat=reftable so 'gvfs clone' produces
// a usable files-format enlistment instead of a broken one. The option only exists
// in git 2.45+, which is also the first git that can create a reftable repo at all,
// so older git needs no pin and would reject the unknown option. A null version
// (undetermined) omits the pin; CloneVerb.TryInitRepo still fails fast if the repo
// turns out to be reftable.
if (SupportsRefFormatOption(installedGitVersion))
{
arguments += " --ref-format=files";
}

arguments += " \"" + enlistment.WorkingDirectoryBackingRoot + "\"";
return enlistment.CreateGitProcess().InvokeGitOutsideEnlistment(arguments);
}

internal static bool SupportsRefFormatOption(GitVersion installedGitVersion)
{
return installedGitVersion != null && !installedGitVersion.IsLessThan(FirstGitVersionSupportingRefFormat);
}

public static ConfigResult GetFromGlobalConfig(string gitBinPath, string settingName)
Expand Down
86 changes: 86 additions & 0 deletions GVFS/GVFS.Common/Git/RefStorage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;

namespace GVFS.Common.Git
{
/// <summary>
/// Detects whether a git repository uses the "reftable" ref storage backend
/// (git's newer, opt-in binary ref format) rather than the "files" backend
/// (loose ref files plus packed-refs) that VFS for Git assumes throughout its
/// ref handling.
///
/// VFS for Git does not populate refs through git's ref backend during a clone -
/// it writes .git/packed-refs directly from the refs returned by the GVFS protocol
/// (see CloneVerb.TryInitRepo and GitRefs.ToPackedRefs). A reftable-backed
/// repository ignores packed-refs entirely, so those refs would be invisible to
/// git and the clone would fail. Other VFS for Git code also reads and writes
/// loose ref files and the .git/logs reflog directly (for example
/// GitHeadRepairJob). Callers should use this detection to fail fast with a clear
/// error rather than let that ref-file-shaped code silently produce a broken
/// enlistment.
/// </summary>
public static class RefStorage
{
public const string RefStorageConfigName = "extensions.refstorage";
public const string ReftableValue = "reftable";

/// <summary>
/// Name of the directory under .git in which the reftable backend stores refs
/// (.git/reftable/). Its presence identifies a reftable repository even when the
/// git config is too corrupt for 'git config' to read - useful for 'gvfs repair',
/// which runs precisely when the config may be unreadable.
/// </summary>
public const string ReftableDirectoryName = "reftable";

public const string UnsupportedReftableErrorMessage =
"VFS for Git only supports repositories that use the \"files\" ref storage format, but this " +
"repository was initialized with the \"reftable\" format. VFS for Git writes refs directly " +
"rather than through git's ref backend, so a reftable repository cannot be used. Re-clone or " +
"re-initialize the repository with files ref storage (for example, 'git init --ref-format=files').";

/// <summary>
/// Returns true if the given value of extensions.refstorage identifies a reftable
/// repository. A missing/empty value means the repository defaults to the "files"
/// ref storage format.
/// </summary>
public static bool IsReftable(string refStorageConfigValue)
{
return string.Equals(refStorageConfigValue?.Trim(), ReftableValue, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Reads extensions.refstorage from the local config of the repo backing the given
/// GitProcess, and returns true if it identifies a reftable repository.
///
/// A config read failure (e.g. a broken git invocation, as opposed to the key simply
/// being absent) is treated the same as "not reftable" here, matching the established
/// behavior of GitProcess.TryGetFromConfig elsewhere in this codebase for optional
/// config reads. Use <see cref="TryIsReftableRepo"/> if the caller wants to distinguish
/// and surface that failure instead of silently treating it as the files format.
/// </summary>
public static bool IsReftableRepo(GitProcess git)
{
TryIsReftableRepo(git, out bool isReftable, out string _);
return isReftable;
}

/// <summary>
/// Reads extensions.refstorage from the local config of the repo backing the given
/// GitProcess. Returns false and populates <paramref name="error"/> if the config
/// could not be read at all (a missing key is not an error - it simply means the
/// repository defaults to the "files" ref storage format, and isReftable is set to
/// false).
/// </summary>
public static bool TryIsReftableRepo(GitProcess git, out bool isReftable, out string error)
{
GitProcess.ConfigResult result = git.GetFromLocalConfig(RefStorageConfigName);
if (!result.TryParseAsString(out string value, out error))
{
isReftable = false;
return false;
}

isReftable = IsReftable(value);
return true;
}
}
}
26 changes: 26 additions & 0 deletions GVFS/GVFS.Mount/InProcessMount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,32 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords)
this.FailMountAndExit("The .git folder is missing or has invalid contents");
}

// Check this before TrySetRequiredGitConfigSettings, which unconditionally
// forces core.repositoryformatversion back to 0 (matching what 'git init'
// writes for a files-format repo) without knowing about extensions.refstorage.
// Applying that write to a reftable repo would leave behind an inconsistent
// config (repositoryformatversion=0 with a v1-only extension still present)
// that git itself then refuses to parse at all - so this check must run first,
// before any other git config mutation. VFS for Git also writes refs directly
// rather than through git's ref backend, so a reftable repo cannot be used
// regardless. A genuine config-read failure is fatal here rather than a
// warning: a missing key reports the repo as files-format and does not reach
// this branch, so a read error means the config is anomalous and proceeding
// into the force-write could brick an unsupported repo.
if (!RefStorage.TryIsReftableRepo(git, out bool isReftableRepo, out string refStorageReadError))
{
// Pass the git error as a format argument, not concatenated into the
// format string: FailMountAndExit routes through ITracer.RelatedError(
// string, params object[]), which runs string.Format, so a '{' in the
// git stderr (possible with a crafted/corrupt .git/config) would throw
// a FormatException instead of failing the mount cleanly.
this.FailMountAndExit("Could not determine the repository's ref storage format: {0}", refStorageReadError);
}
else if (isReftableRepo)
{
this.FailMountAndExit(RefStorage.UnsupportedReftableErrorMessage);
}

if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError))
{
this.FailMountAndExit("FileSystem unsupported: " + fsError);
Expand Down
78 changes: 76 additions & 2 deletions GVFS/GVFS.UnitTests/Git/GitProcessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,23 +142,97 @@ private static List<string> ReadTokens(string content)
}

[TestCase]
public void Init_PinsObjectFormatToSha1()
public void Init_PinsObjectFormatToSha1_AndOmitsRefFormatWhenVersionUnknown()
{
MockGitProcess gitProcess = new MockGitProcess();
MockGVFSEnlistment enlistment = new MockGVFSEnlistment(gitProcess);

// object-format=sha1 is always pinned. A null (undetermined) git version also
// omits the version-gated --ref-format pin - CloneVerb.TryInitRepo's reftable
// detection is the safety net in that case.
string expectedCommand = "init --object-format=sha1 \"" + enlistment.WorkingDirectoryBackingRoot + "\"";
gitProcess.SetExpectedCommandResult(
expectedCommand,
() => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.SuccessCode));

GitProcess.Result result = GitProcess.Init(enlistment);
GitProcess.Result result = GitProcess.Init(enlistment, installedGitVersion: null);

result.ExitCodeIsFailure.ShouldBeFalse();
gitProcess.CommandsRun.Count.ShouldEqual(1);
gitProcess.CommandsRun[0].ShouldEqual(expectedCommand);
}

[TestCase]
public void Init_PinsRefFormatToFiles_WhenGitSupportsRefFormat()
{
MockGitProcess gitProcess = new MockGitProcess();
MockGVFSEnlistment enlistment = new MockGVFSEnlistment(gitProcess);

string expectedCommand = "init --object-format=sha1 --ref-format=files \"" + enlistment.WorkingDirectoryBackingRoot + "\"";
gitProcess.SetExpectedCommandResult(
expectedCommand,
() => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.SuccessCode));

GitProcess.Result result = GitProcess.Init(enlistment, new GitVersion(2, 45, 0, (string)null));

result.ExitCodeIsFailure.ShouldBeFalse();
gitProcess.CommandsRun.Count.ShouldEqual(1);
gitProcess.CommandsRun[0].ShouldEqual(expectedCommand);
}

[TestCase]
public void Init_DoesNotPinRefFormat_WhenGitPredatesRefFormat()
{
MockGitProcess gitProcess = new MockGitProcess();
MockGVFSEnlistment enlistment = new MockGVFSEnlistment(gitProcess);

string expectedCommand = "init --object-format=sha1 \"" + enlistment.WorkingDirectoryBackingRoot + "\"";
gitProcess.SetExpectedCommandResult(
expectedCommand,
() => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.SuccessCode));

GitProcess.Result result = GitProcess.Init(enlistment, new GitVersion(2, 44, 0, (string)null));

result.ExitCodeIsFailure.ShouldBeFalse();
gitProcess.CommandsRun.Count.ShouldEqual(1);
gitProcess.CommandsRun[0].ShouldEqual(expectedCommand);
}

[TestCase(2, 45, 0, true)] // first version with --ref-format
[TestCase(2, 55, 0, true)] // newer
[TestCase(2, 44, 0, false)] // one minor before
[TestCase(2, 31, 0, false)] // MinimumGitVersion
[TestCase(1, 99, 0, false)] // older major
public void SupportsRefFormatOption_GatesOnGit245(int major, int minor, int build, bool expected)
{
GitProcess.SupportsRefFormatOption(new GitVersion(major, minor, build, (string)null)).ShouldEqual(expected);
}

[TestCase]
public void SupportsRefFormatOption_IsTrueForMicrosoftGitBuildOf245()
{
// Microsoft/Git versions carry a platform + revision suffix (e.g. 2.45.0.vfs.0.1);
// the comparison must treat such a build of 2.45 as supporting --ref-format.
GitProcess.SupportsRefFormatOption(new GitVersion(2, 45, 0, "vfs", 0, 1)).ShouldEqual(true);
}

[TestCase]
public void SupportsRefFormatOption_IsFalseForReleaseCandidateOf245()
{
// A release candidate of 2.45 sorts before the final 2.45 (GitVersion treats a
// version with a release candidate as less than the same version without one), so
// the gate conservatively omits the pin for an rc build. That is a missed
// optimization, not a correctness problem: the clone falls back to un-pinned init
// exactly as on pre-2.45 git, and the reftable detection still catches the repo.
GitProcess.SupportsRefFormatOption(new GitVersion(2, 45, 0, releaseCandidate: 0)).ShouldEqual(false);
}

[TestCase]
public void SupportsRefFormatOption_IsFalseForNullVersion()
{
GitProcess.SupportsRefFormatOption(null).ShouldEqual(false);
}

[TestCase]
public void BoundedGitOutputBuffer_KeepsShortOutput()
{
Expand Down
Loading
Loading