From 2548e41f82c30e3624f611591ae8eb938be3dadd Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 24 Sep 2026 12:52:23 -0700 Subject: [PATCH] Fail fast on reftable repositories, and pin clone to files ref storage VFS for Git assumes the "files" ref storage format. It does not populate refs through git's ref backend during a clone; it writes .git/packed-refs directly from the refs the GVFS protocol returns (CloneVerb.TryInitRepo and GitRefs.ToPackedRefs). A reftable-backed repository ignores packed-refs, so those refs are invisible to git and the enlistment is broken. Other code reads and writes loose ref files and the .git/logs reflog directly. A user who sets init.defaultRefFormat=reftable globally gets a reftable repo from 'gvfs clone' today, and reftable is git's future default ref backend. Until now that produced no clear error: RequiredGitConfig force-writes core.repositoryformatversion=0 on clone, on every mount, and on repair, which leaves repositoryformatversion=0 next to a v1-only extension that git then refuses to parse at all. Pin 'git init' to --ref-format=files during clone, mirroring the existing --object-format=sha1 pin, so init.defaultRefFormat=reftable no longer produces a reftable repo. The option and the reftable backend both first shipped in git 2.45, so the pin is gated on the installed git version; older git cannot create a reftable repo and would reject the unknown option. The clone flow already resolves and validates the git version (GVFSVerb.CheckGitVersion), so GitProcess.Init takes that version as a parameter rather than shelling out for 'git --version' again. If the version is unknown, the pin is omitted and detection still catches the repo. Add GVFS.Common.Git.RefStorage to detect extensions.refstorage=reftable and fail fast with an actionable message before any ref write or config mutation, at the call sites that would otherwise reach the corrupting force-write: FastFetch, clone (TryInitRepo, right after git init), mount (before TrySetRequiredGitConfigSettings), and repair (before the config rebuild). This mirrors the object-format (sha256) fail-fast helper and covers pre-existing repos and the git-version-undetermined case that the clone pin cannot. A genuine config-read failure is fatal at mount, clone, and FastFetch: a missing key already reports the repository as files-format, so a read error means the config is anomalous and continuing risks the very brick this guards against. Mount passes the git error to FailMountAndExit as a format argument so a brace in git stderr cannot throw a FormatException instead of failing the mount cleanly. Repair instead surfaces the read error and proceeds, because repair exists to rebuild a corrupt config; it refuses a reftable repo detected either by config or by the physical .git/reftable/ directory, so a corrupt reftable repo is not silently rebuilt as files. GitConfigRepairJob.HasIssue also reports a reftable enlistment as an unfixable issue so 'gvfs diagnose'/'gvfs repair' no longer call it healthy. Add unit tests for the version-gated ref-format pin (including the release candidate boundary and the version-unknown fallback) and for RefStorage value parsing and config reads, including the genuine read-failure path. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- GVFS/FastFetch/FastFetchVerb.cs | 18 +++ GVFS/GVFS.Common/Git/GitProcess.cs | 40 +++++- GVFS/GVFS.Common/Git/RefStorage.cs | 86 ++++++++++++ GVFS/GVFS.Mount/InProcessMount.cs | 26 ++++ GVFS/GVFS.UnitTests/Git/GitProcessTests.cs | 78 ++++++++++- GVFS/GVFS.UnitTests/Git/RefStorageTests.cs | 145 +++++++++++++++++++++ GVFS/GVFS/CommandLine/CloneVerb.cs | 37 +++++- GVFS/GVFS/RepairJobs/GitConfigRepairJob.cs | 60 +++++++++ 8 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 GVFS/GVFS.Common/Git/RefStorage.cs create mode 100644 GVFS/GVFS.UnitTests/Git/RefStorageTests.cs diff --git a/GVFS/FastFetch/FastFetchVerb.cs b/GVFS/FastFetch/FastFetchVerb.cs index cf6add3aa..0b0c3147a 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 f3100a90c..e4a80cd5b 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 000000000..12bace313 --- /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 0a5c930b5..a6b5d9e94 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 31d6fe2c8..64ba6b71b 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 000000000..b3a02995c --- /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 2e9bb5276..ce579d045 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 2903bcf72..a7b33df88 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); + } } }