diff --git a/GVFS/FastFetch/FastFetchVerb.cs b/GVFS/FastFetch/FastFetchVerb.cs index cf6add3aad..0b0c3147a7 100644 --- a/GVFS/FastFetch/FastFetchVerb.cs +++ b/GVFS/FastFetch/FastFetchVerb.cs @@ -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)) { diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index f3100a90cf..e4a80cd5b2 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -51,6 +51,13 @@ public class GitProcess : ICredentialStore private static bool failedToSetEncoding = false; private static string expireTimeDateString; + /// + /// 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. + /// + private static readonly GitVersion FirstGitVersionSupportingRefFormat = new GitVersion(2, 45, 0, (string)null); + /// /// Lock taken for duration of running executingProcess. /// @@ -130,13 +137,42 @@ public static string ExpireTimeDateString public bool LowerPriority { get; set; } - public static Result Init(Enlistment enlistment) + /// + /// 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. + /// + 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) diff --git a/GVFS/GVFS.Common/Git/RefStorage.cs b/GVFS/GVFS.Common/Git/RefStorage.cs new file mode 100644 index 0000000000..12bace313f --- /dev/null +++ b/GVFS/GVFS.Common/Git/RefStorage.cs @@ -0,0 +1,86 @@ +using System; + +namespace GVFS.Common.Git +{ + /// + /// 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. + /// + public static class RefStorage + { + public const string RefStorageConfigName = "extensions.refstorage"; + public const string ReftableValue = "reftable"; + + /// + /// 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. + /// + 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')."; + + /// + /// 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. + /// + public static bool IsReftable(string refStorageConfigValue) + { + return string.Equals(refStorageConfigValue?.Trim(), ReftableValue, StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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 if the caller wants to distinguish + /// and surface that failure instead of silently treating it as the files format. + /// + public static bool IsReftableRepo(GitProcess git) + { + TryIsReftableRepo(git, out bool isReftable, out string _); + return isReftable; + } + + /// + /// Reads extensions.refstorage from the local config of the repo backing the given + /// GitProcess. Returns false and populates 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). + /// + 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; + } + } +} diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 0a5c930b5f..a6b5d9e94b 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -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); diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index 31d6fe2c85..64ba6b71b5 100644 --- a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs @@ -142,23 +142,97 @@ private static List 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() { diff --git a/GVFS/GVFS.UnitTests/Git/RefStorageTests.cs b/GVFS/GVFS.UnitTests/Git/RefStorageTests.cs new file mode 100644 index 0000000000..b3a02995c0 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Git/RefStorageTests.cs @@ -0,0 +1,145 @@ +using GVFS.Common.Git; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Git; +using NUnit.Framework; + +namespace GVFS.UnitTests.Git +{ + [TestFixture] + public class RefStorageTests + { + private const string ConfigCommand = "config --local " + RefStorage.RefStorageConfigName; + + [TestCase] + public void IsReftableIsTrueForReftableValue() + { + RefStorage.IsReftable("reftable").ShouldEqual(true); + } + + [TestCase] + public void IsReftableIsTrueForMixedCaseReftableValue() + { + RefStorage.IsReftable("Reftable").ShouldEqual(true); + } + + [TestCase] + public void IsReftableIsTrueForReftableValueWithSurroundingWhitespace() + { + RefStorage.IsReftable(" reftable \n").ShouldEqual(true); + } + + [TestCase] + public void IsReftableIsFalseForFilesValue() + { + RefStorage.IsReftable("files").ShouldEqual(false); + } + + [TestCase] + public void IsReftableIsFalseForNullValue() + { + RefStorage.IsReftable(null).ShouldEqual(false); + } + + [TestCase] + public void IsReftableIsFalseForEmptyValue() + { + RefStorage.IsReftable(string.Empty).ShouldEqual(false); + } + + [TestCase] + public void IsReftableRepoIsTrueWhenConfigIsReftable() + { + MockGitProcess git = new MockGitProcess(); + git.SetExpectedCommandResult( + ConfigCommand, + () => new GitProcess.Result("reftable\n", string.Empty, GitProcess.Result.SuccessCode)); + + RefStorage.IsReftableRepo(git).ShouldEqual(true); + } + + [TestCase] + public void IsReftableRepoIsFalseWhenConfigIsFiles() + { + MockGitProcess git = new MockGitProcess(); + git.SetExpectedCommandResult( + ConfigCommand, + () => new GitProcess.Result("files\n", string.Empty, GitProcess.Result.SuccessCode)); + + RefStorage.IsReftableRepo(git).ShouldEqual(false); + } + + [TestCase] + public void IsReftableRepoIsFalseWhenConfigIsMissing() + { + MockGitProcess git = new MockGitProcess(); + + // A missing config key causes 'git config' to exit non-zero with no stderr output, + // which is the observed behavior for a files-format repo that has no + // extensions.refstorage key. + git.SetExpectedCommandResult( + ConfigCommand, + () => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.GenericFailureCode)); + + RefStorage.IsReftableRepo(git).ShouldEqual(false); + } + + [TestCase] + public void IsReftableRepoSwallowsGenuineReadFailureAndReturnsFalse() + { + MockGitProcess git = new MockGitProcess(); + + // The non-Try overload deliberately swallows a genuine config-read failure + // (non-zero exit with real stderr content) and reports "not reftable", matching + // GitProcess.TryGetFromConfig's "failure == not set" convention. Callers that + // need to distinguish a read failure use TryIsReftableRepo instead. + git.SetExpectedCommandResult( + ConfigCommand, + () => new GitProcess.Result(string.Empty, "fatal: not a git repository", GitProcess.Result.GenericFailureCode)); + + RefStorage.IsReftableRepo(git).ShouldEqual(false); + } + + [TestCase] + public void TryIsReftableRepoSucceedsWithNoErrorWhenConfigIsReftable() + { + MockGitProcess git = new MockGitProcess(); + git.SetExpectedCommandResult( + ConfigCommand, + () => new GitProcess.Result("reftable\n", string.Empty, GitProcess.Result.SuccessCode)); + + RefStorage.TryIsReftableRepo(git, out bool isReftable, out string error).ShouldEqual(true); + isReftable.ShouldEqual(true); + error.ShouldEqual(string.Empty); + } + + [TestCase] + public void TryIsReftableRepoSucceedsWithNoErrorWhenConfigIsMissing() + { + MockGitProcess git = new MockGitProcess(); + git.SetExpectedCommandResult( + ConfigCommand, + () => new GitProcess.Result(string.Empty, string.Empty, GitProcess.Result.GenericFailureCode)); + + RefStorage.TryIsReftableRepo(git, out bool isReftable, out string error).ShouldEqual(true); + isReftable.ShouldEqual(false); + error.ShouldEqual(string.Empty); + } + + [TestCase] + public void TryIsReftableRepoFailsAndReportsErrorWhenConfigReadFails() + { + MockGitProcess git = new MockGitProcess(); + + // A genuine 'git config' failure (non-zero exit with real stderr content, as + // opposed to a missing key) should be surfaced distinctly rather than silently + // treated the same as "not reftable". + git.SetExpectedCommandResult( + ConfigCommand, + () => new GitProcess.Result(string.Empty, "fatal: not a git repository", GitProcess.Result.GenericFailureCode)); + + RefStorage.TryIsReftableRepo(git, out bool isReftable, out string error).ShouldEqual(false); + isReftable.ShouldEqual(false); + error.ShouldNotBeNull(); + } + } +} diff --git a/GVFS/GVFS/CommandLine/CloneVerb.cs b/GVFS/GVFS/CommandLine/CloneVerb.cs index 2e9bb52762..ce579d0459 100644 --- a/GVFS/GVFS/CommandLine/CloneVerb.cs +++ b/GVFS/GVFS/CommandLine/CloneVerb.cs @@ -665,7 +665,14 @@ private Result CreateClone( GitRefs refs, string branch) { - Result initRepoResult = this.TryInitRepo(tracer, refs, enlistment); + // The clone flow already resolved and validated the installed git version + // (GVFSVerb.ValidateClientVersions -> CheckGitVersion, stored on the enlistment), + // so parse that instead of shelling out for 'git --version' again inside Init. A + // parse failure yields null, which conservatively omits the '--ref-format=files' + // pin; TryInitRepo's reftable detection still catches the repo in that case. + GitVersion.TryParseVersion(enlistment.GitVersion, out GitVersion installedGitVersion); + + Result initRepoResult = this.TryInitRepo(tracer, refs, enlistment, installedGitVersion); if (!initRepoResult.Success) { return initRepoResult; @@ -846,10 +853,10 @@ cd src gitCmd.Attributes = FileAttributes.Hidden; } - private Result TryInitRepo(ITracer tracer, GitRefs refs, Enlistment enlistmentToInit) + private Result TryInitRepo(ITracer tracer, GitRefs refs, Enlistment enlistmentToInit, GitVersion installedGitVersion) { string repoPath = enlistmentToInit.WorkingDirectoryBackingRoot; - GitProcess.Result initResult = GitProcess.Init(enlistmentToInit); + GitProcess.Result initResult = GitProcess.Init(enlistmentToInit, installedGitVersion); if (initResult.ExitCodeIsFailure) { string error = string.Format("Could not init repo at to {0}: {1}", repoPath, initResult.Errors); @@ -857,6 +864,30 @@ private Result TryInitRepo(ITracer tracer, GitRefs refs, Enlistment enlistmentTo return new Result(error); } + // 'git init' above pins --ref-format=files when the installed git supports it (git + // 2.45+), so a user's init.defaultRefFormat=reftable no longer produces a reftable + // repo on a supported git. This check remains as defense-in-depth: it covers the + // case where the git version could not be determined (the pin is then omitted) and + // any repo that reached v1 by another route. VFS for Git populates refs by writing + // .git/packed-refs directly (below, and via GitRefs.ToPackedRefs) rather than + // through git's ref backend, and a reftable repo ignores packed-refs - so such a + // clone would silently produce an enlistment whose remote refs are invisible to + // git. Fail fast with a clear error instead. A genuine config-read failure is also + // fatal here: this is our own just-initialized repo, so a read error (as opposed to + // the key being absent, which reports files-format) means its config is unreadable + // and the clone cannot safely continue. + if (!RefStorage.TryIsReftableRepo(enlistmentToInit.CreateGitProcess(), out bool isReftableRepo, out string refStorageReadError)) + { + string readError = "Could not determine the new repository's ref storage format: " + refStorageReadError; + tracer.RelatedError(readError); + return new Result(readError); + } + else if (isReftableRepo) + { + tracer.RelatedError(RefStorage.UnsupportedReftableErrorMessage); + return new Result(RefStorage.UnsupportedReftableErrorMessage); + } + try { GVFSPlatform.Instance.FileSystem.EnsureDirectoryIsOwnedByCurrentUser(enlistmentToInit.DotGitRoot); diff --git a/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs b/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs index 2903bcf72d..a7b33df884 100644 --- a/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs +++ b/GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs @@ -22,6 +22,22 @@ public override string Name public override IssueType HasIssue(List messages) { GitProcess git = new GitProcess(this.Enlistment); + + // A reftable repository is not something 'gvfs repair' can make usable (see + // TryFixIssues), so report it as an unfixable issue rather than letting + // 'gvfs diagnose'/'gvfs repair' declare the enlistment healthy. Detect it both + // from the config extension and from the physical .git/reftable/ directory: the + // latter still identifies a reftable repo when the config is too corrupt for + // 'git config' to read, which is exactly the state repair runs in. A config-read + // failure alone is otherwise swallowed as "not reftable" so the normal config + // checks below still run - repair exists to diagnose exactly that kind of broken + // config. + if (RefStorage.IsReftableRepo(git) || this.ReftableBackendDirectoryExists()) + { + messages.Add(RefStorage.UnsupportedReftableErrorMessage); + return IssueType.CantFix; + } + GitProcess.ConfigResult originResult = git.GetOriginUrl(); string error; string originUrl; @@ -74,6 +90,37 @@ public override IssueType HasIssue(List messages) public override FixResult TryFixIssues(List messages) { + // Check before touching the config file at all: TryFixIssues rebuilds the + // entire config from scratch (wiping it to empty, then writing only the + // required/optional settings this codebase knows about via + // TrySetRequiredGitConfigSettings, which force-writes + // core.repositoryformatversion=0 and drops extensions.refstorage). Rebuilding + // a reftable repo's config that way would strip the reftable extension while + // its refs still live in the reftable backend, producing a differently-broken + // repo. There is no way for 'gvfs repair' to make a reftable repo usable by + // VFS for Git, so fail clearly instead of attempting the rebuild. + // + // Detect reftable from the config AND from the physical .git/reftable/ directory. + // The directory check is what makes this safe when the config is unreadable: a + // corrupt reftable repo would otherwise fall through to the rebuild below and be + // silently converted to a files-format config while its refs remain in reftable + // storage. A config-read failure with no reftable directory is NOT blocked, + // unlike at mount/clone/FastFetch: repair's whole purpose is to rebuild a corrupt + // files-format config, so a repo whose config is merely unreadable is exactly what + // repair must be allowed to fix. + bool reftableDirectoryExists = this.ReftableBackendDirectoryExists(); + bool configReadable = RefStorage.TryIsReftableRepo(new GitProcess(this.Enlistment), out bool isReftableByConfig, out string refStorageReadError); + if (reftableDirectoryExists || (configReadable && isReftableByConfig)) + { + messages.Add(RefStorage.UnsupportedReftableErrorMessage); + return FixResult.Failure; + } + + if (!configReadable) + { + this.Tracer.RelatedWarning("Could not determine the repository's ref storage format; proceeding with repair: " + refStorageReadError); + } + string configPath = Path.Combine(this.Enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Config); string configBackupPath; if (!this.TryRenameToBackupFile(configPath, out configBackupPath, messages)) @@ -119,5 +166,18 @@ public override FixResult TryFixIssues(List messages) return FixResult.ManualStepsRequired; } + + private bool ReftableBackendDirectoryExists() + { + // DotGitRoot assumes .git is a directory (WorkingDirectoryBackingRoot\.git), which + // is how the whole Enlistment abstraction models it. That holds here because + // 'gvfs repair' targets the primary enlistment, whose .git is always a real + // directory - not a linked git worktree, where .git is a file. If repair is ever + // taught to run against a linked worktree, this directory probe would need to + // resolve the gitdir from that .git file first. + string reftableDirectory = Path.Combine(this.Enlistment.DotGitRoot, RefStorage.ReftableDirectoryName); + + return Directory.Exists(reftableDirectory); + } } }