From dda85a424a320554d059338314338a166d7dad8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Mon, 5 Oct 2026 10:41:58 +0200 Subject: [PATCH] Keep the history of existing clones when old commits are deleted A repository whose oldest commits were deleted announced the oldest remaining commit as a shallow boundary to every client - also to a clone made before, which still has the deleted parents. Git refuses to update a complete clone to commits behind a shallow root ("shallow roots are not allowed to be updated"), so such a clone could not fetch anything newer. The commits the history refers to but no longer has are now known by their ids: a client that has one is acknowledged as having it, and a commit based on it is no shallow boundary for that client. Bumps the version to 0.1.1. Co-Authored-By: Claude Opus 5.5 --- Directory.Build.props | 6 +- Module/Protocol/FetchPlanner.cs | 44 +++++++++++++- Module/Protocol/RepositoryContext.cs | 57 +++++++++++++++++++ .../Integration/VersionedRepositoryTests.cs | 33 +++++++++++ docs/concepts.md | 4 +- docs/repositories.md | 6 +- 6 files changed, 141 insertions(+), 9 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0eadbe6..ca4982d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,9 +11,9 @@ true enable - 0.1.0.0 - 0.1.0.0 - 0.1.0 + 0.1.1.0 + 0.1.1.0 + 0.1.1 Andreas Nägeli Copyright © Andreas Nägeli diff --git a/Module/Protocol/FetchPlanner.cs b/Module/Protocol/FetchPlanner.cs index 0258502..b171e35 100644 --- a/Module/Protocol/FetchPlanner.cs +++ b/Module/Protocol/FetchPlanner.cs @@ -89,7 +89,15 @@ public static async ValueTask> FindCommonAsync(RepositoryConte foreach (var have in haves) { - if (seen.Add(have) && await context.GetCommitAsync(have) != null) + if (!seen.Add(have)) + { + continue; + } + + // also the commits the history no longer has, which a client that + // cloned before it was truncated still has - acknowledged, it + // keeps saying so for the rest of the negotiation + if (await context.GetCommitAsync(have) != null || (await context.GetTruncatedAsync()).Contains(have)) { result.Add(have); } @@ -115,6 +123,8 @@ public static async ValueTask PlanAsync(RepositoryContext context, Fe var owned = await GetOwnedAsync(context, request, common); + var haves = request.Haves.ToHashSet(); + var update = await GetShallowUpdateAsync(context, request); var shallow = new List(update.Shallow); @@ -172,8 +182,10 @@ public static async ValueTask PlanAsync(RepositoryContext context, Fe if (parents == null) { - // the history of the repository ends here - if (!request.ClientShallows.Contains(id) && !shallow.Contains(id)) + // the history of the repository ends here - unless the client + // still has what came before (e.g. a clone made before old + // versions were deleted), which it would refuse to cut off + if (!request.ClientShallows.Contains(id) && !shallow.Contains(id) && !HasHistory(commit, haves, owned)) { shallow.Add(id); } @@ -246,6 +258,20 @@ public static async ValueTask PlanAsync(RepositoryContext context, Fe return new FetchPlan(shallow, update.Unshallow, objects); } + /// + /// Whether the client has the history before a commit whose parents the + /// repository does not have (anymore): it said it has every one of them, + /// or they are part of what it has with the common commits. + /// + /// + /// Announcing such a commit as shallow would cut off history the client + /// already has, which git refuses ("shallow roots are not allowed to be + /// updated") - so a clone made before old commits were deleted could not + /// fetch anything newer. + /// + private static bool HasHistory(GitCommit commit, HashSet haves, HashSet owned) + => commit.Parents.Count > 0 && commit.Parents.All(p => haves.Contains(p) || owned.Contains(p)); + /// /// Collects the commits the client has: everything reachable from the /// common commits, cut at the shallow boundaries of the client. @@ -262,6 +288,18 @@ private static async ValueTask> GetOwnedAsync(RepositoryCon } } + // what the history no longer has but the client does: the commits + // based on it are connected to what the client has + var truncated = await context.GetTruncatedAsync(); + + foreach (var commit in common) + { + if (truncated.Contains(commit)) + { + owned.Add(commit); + } + } + return owned; } diff --git a/Module/Protocol/RepositoryContext.cs b/Module/Protocol/RepositoryContext.cs index 11145a2..723654f 100644 --- a/Module/Protocol/RepositoryContext.cs +++ b/Module/Protocol/RepositoryContext.cs @@ -20,6 +20,14 @@ internal sealed class RepositoryContext(IGitRepository repository, ContentCache private readonly Dictionary _trees = new(); + private HashSet? _truncated; + + /// + /// How many commits are searched for the ones the history refers to but + /// does not have. + /// + private const int SearchLimit = 10_000; + #region Get-/Setters public IGitRepository Repository => repository; @@ -147,6 +155,55 @@ public async ValueTask> GetAncestryAsync(IEnumerable + /// The commits the history of the repository refers to but does not have + /// (anymore): the parents of the commits it starts with, if it has been + /// truncated. + /// + /// + /// A client that cloned before the history was truncated still has them. + /// Knowing them lets the server tell this client that it has the history + /// before a truncation, instead of cutting it off as shallow - which git + /// refuses for a clone that is not shallow. Only the newest commits of + /// the repository are searched, as for the objects a client did not send. + /// + public async ValueTask> GetTruncatedAsync() + { + if (_truncated != null) + { + return _truncated; + } + + var result = new HashSet(); + + var visited = new HashSet(); + + var pending = new Stack((await GetReferencesAsync()).Select(r => r.Target)); + + while (pending.TryPop(out var id) && visited.Count < SearchLimit) + { + if (!visited.Add(id)) + { + continue; + } + + var commit = await GetCommitAsync(id); + + if (commit == null) + { + result.Add(id); + continue; + } + + foreach (var parent in commit.Parents) + { + pending.Push(parent); + } + } + + return _truncated = result; + } + /// /// Finds the commits reachable from the given ones whose history has /// been truncated, which need to be announced as shallow to clients. diff --git a/Testing/Integration/VersionedRepositoryTests.cs b/Testing/Integration/VersionedRepositoryTests.cs index e70f7fd..adf1930 100644 --- a/Testing/Integration/VersionedRepositoryTests.cs +++ b/Testing/Integration/VersionedRepositoryTests.cs @@ -299,4 +299,37 @@ public async Task TestRetention(int protocol) await git.RunAsync("clone", "fsck"); } + [TestMethod] + [DataRow(0)] + [DataRow(2)] + public async Task TestRetentionKeepsTheHistoryOfExistingClones(int protocol) + { + var repository = await CreateAsync(3); + + repository.Retention = 2; + + await using var host = await TestHost.RunAsync(GitServer.Create().Repository(repository)); + + using var git = new GitClient(protocol); + + await git.RunAsync(null, "clone", host.GetUrl("/"), "clone"); + + // the versions the clone has are deleted while newer ones are saved + await repository.SaveVersionAsync(Files(("lambda.cs", "return 4;")), "Version 4"); + await repository.SaveVersionAsync(Files(("lambda.cs", "return 5;")), "Version 5"); + + await git.RunAsync("clone", "pull", "--ff-only"); + + Assert.AreEqual("Version 5\nVersion 4\nVersion 3\nVersion 2\nVersion 1\n", await git.RunAsync("clone", "log", "--format=%s")); + Assert.AreEqual("false\n", await git.RunAsync("clone", "rev-parse", "--is-shallow-repository"), "the history the clone had is kept"); + + await git.RunAsync("clone", "fsck", "--strict"); + + // a new clone starts where the repository does + await git.RunAsync(null, "clone", host.GetUrl("/"), "fresh"); + + Assert.AreEqual("Version 5\nVersion 4\n", await git.RunAsync("fresh", "log", "--format=%s")); + Assert.AreEqual("true\n", await git.RunAsync("fresh", "rev-parse", "--is-shallow-repository")); + } + } diff --git a/docs/concepts.md b/docs/concepts.md index d65683d..cad4b7d 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -110,4 +110,6 @@ A typical incremental fetch therefore only reads the files of the new commits an If your data model deletes old content (e.g. only the newest 50 versions are kept), the oldest remaining commit refers to a parent that no longer exists. Return `null` from `GetCommitAsync` for such commits: the server treats the repository as shallow and tells clients that the history ends there, exactly like a shallow clone created with -`git clone --depth`. Clients that already have older commits keep them. +`git clone --depth`. Clients that already have older commits keep them: a client that says it has the parent of +the oldest remaining commit is told it has the history before it, so it goes on fetching newer commits as a +complete clone rather than being cut off as a shallow one (which git refuses). diff --git a/docs/repositories.md b/docs/repositories.md index f4e57b0..5888e4d 100644 --- a/docs/repositories.md +++ b/docs/repositories.md @@ -154,8 +154,10 @@ against its id. ## Truncated histories If old commits are deleted, return `null` from `GetCommitAsync` for them. The oldest remaining commit then acts as -the shallow boundary of the repository, and clients receive a shallow clone. References must only point to commits -that still exist. +the shallow boundary of the repository, and clients receive a shallow clone. Clients that cloned before keep their +history and keep fetching: the server recognizes the deleted parents of the remaining commits by their ids (as the +remaining commits name them), and does not cut off a client that still has them. References must only point to +commits that still exist. ## Read-only access