Let a host carry pinned folders, merged path by path

This commit is contained in:
2026-08-07 15:25:51 +02:00
parent 82966af37b
commit 49645db680
8 changed files with 633 additions and 7 deletions
@@ -58,6 +58,7 @@ public sealed class HostSecretMergeTests
SshKeyId = DeployKey,
GroupId = Production,
TagIds = TagSet.Create([Pci]),
PinnedPaths = PinnedPathList.Create(["/var/www/app"]),
};
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
@@ -160,6 +161,102 @@ public sealed class HostSecretMergeTests
private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : [];
[Fact]
public void TwoPeoplePinningDifferentPathsToOneHost_BothKeepTheirs()
{
// The same reason TagIds merges per tag rather than as a whole value: a whole-value merge would
// take one side's list entire and drop the other's.
var ancestor = Host();
var result = HostSecretMerge.Merge(
ancestor,
ancestor with { PinnedPaths = PinnedPathList.Create(["/var/www/app"]) },
ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) });
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void PinnedPathAdditions_ComeOutInBaseOrderThenLocalThenRemote()
{
// The order guarantee this merge exists to keep: unlike a tag chip, a pinned path's position is
// part of what the user set, so the merge must produce a deterministic order rather than whatever
// a set union happens to yield.
var ancestor = Host(pinnedPaths: ["/base/one", "/base/two"]);
var result = HostSecretMerge.Merge(
ancestor,
ancestor with
{
PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/local/added"]),
},
ancestor with
{
PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/remote/added"]),
});
result.Merged.PinnedPaths.ShouldBe(
PinnedPathList.Create(["/base/one", "/base/two", "/local/added", "/remote/added"]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void OneSideRemovingAPinnedPathWhileTheOtherAddsAnother_KeepsBothDecisions()
{
// Each path is resolved on its own, so a removal on one side and an addition on the other are two
// independent answers rather than two versions of one. A whole-value merge would have to pick.
var ancestor = Host(pinnedPaths: ["/var/www/app"]);
var result = HostSecretMerge.Merge(
ancestor,
ancestor with { PinnedPaths = PinnedPathList.Empty },
ancestor with
{
PinnedPaths = PinnedPathList.Create(["/var/www/app", "/var/log"]),
});
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void APinnedPathRemovedOnBothSides_IsNotResurrected()
{
var ancestor = Host(pinnedPaths: ["/var/www/app", "/var/log"]);
var withoutApp = ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) };
var result = HostSecretMerge.Merge(ancestor, withoutApp, withoutApp);
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
result.HasConflicts.ShouldBeFalse();
}
/// <inheritdoc cref="NoArrangementOfOneTag_ProducesAConflict" />
[Theory]
[InlineData(true, true, true)]
[InlineData(true, true, false)]
[InlineData(true, false, true)]
[InlineData(true, false, false)]
[InlineData(false, true, true)]
[InlineData(false, true, false)]
[InlineData(false, false, true)]
[InlineData(false, false, false)]
public void NoArrangementOfOnePinnedPath_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote)
{
var result = HostSecretMerge.Merge(
Host(pinnedPaths: Pinning(inAncestor)),
Host(pinnedPaths: Pinning(inLocal)),
Host(pinnedPaths: Pinning(inRemote)));
result.HasConflicts.ShouldBeFalse();
result.Merged.PinnedPaths.Contains("/var/www/app")
.ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote);
}
private static string[] Pinning(bool pinned) => pinned ? ["/var/www/app"] : [];
[Fact]
public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict()
{