Let a host carry pinned folders, merged path by path

This commit is contained in:
jaap-jan committed 2026-08-07 15:25:51 +02:00
1 parent 82966af37b
commit 49645db680
8 files changed
+633 -7

No files matched your search

@@ -97,6 +97,8 @@ public static class HostSecretMerge
FormatChain),
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
PinnedPaths = MergePinnedPaths(
ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
RelayEnabled = Field(
nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled,
@@ -290,6 +292,56 @@ public static class HostSecretMerge
return TagSet.Create(merge.Merged.Keys);
}
/// <summary>
/// Merges the pinned paths per path, so two people each pinning a different one both keep theirs.
/// </summary>
/// <remarks>
/// <para>
/// The same reasoning as <see cref="MergeTags"/>, and the same shape: a whole-value merge would take
/// one side's list entire and drop the other's, so a colleague pinning <c>/var/log</c> while you pinned
/// <c>/var/www/app</c> would silently lose one of the two.
/// </para>
/// <para>
/// <b>Unlike a tag set, order survives.</b> <see cref="PinnedPathList.ToPathMap"/> is built in list
/// order rather than a canonical one, and <see cref="ThreeWayMerge.Map"/> unions keys ancestor first,
/// then local, then remote — so the merged keys come out base order, then this machine's additions,
/// then the server's, which is what <see cref="PinnedPathList.Create(IEnumerable{string})"/> then
/// preserves rather than re-sorting.
/// </para>
/// <para>
/// <b>No conflict is reachable here either, for the reason <see cref="MergeTags"/> gives.</b> The value
/// in the map is the key, so a path can only be present or absent, and the "both sides moved
/// differently" branch of the keyed merge needs one key to hold two values — which this map cannot
/// express. The loop stays for the same reason it stays there: the proof depends on
/// <see cref="PinnedPathList.ToPathMap"/> keying by value, and that is one edit away from no longer
/// being true.
/// </para>
/// </remarks>
private static PinnedPathList MergePinnedPaths(
PinnedPathList ancestor,
PinnedPathList local,
PinnedPathList remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToPathMap(),
local.ToPathMap(),
remote.ToPathMap(),
StringComparer.Ordinal);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.PinnedPaths)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept is null ? "not pinned" : "pinned",
conflict.DiscardedWasRemoval ? "not pinned" : "pinned",
conflict.DiscardedWasRemoval));
}
return PinnedPathList.Create(merge.Merged.Keys);
}
private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
}