Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8277851
Add AGENTS.md and gitignore local agent skill files.
hahn-kev Jul 10, 2026
fcc9bc4
Add pluggable commit materialization filter for scoped visibility.
hahn-kev Jul 16, 2026
5fdf45c
Add optional SIL.Harmony.Refs package with create-branch support.
hahn-kev Jul 16, 2026
93e8b14
Clean solution entry for SIL.Harmony.Refs.
hahn-kev Jul 16, 2026
23319c2
Add branch-scoped authoring with main-line query isolation.
hahn-kev Jul 16, 2026
82ad90a
Add checkout-aware branch views with rematerialization.
hahn-kev Jul 16, 2026
bfa3aaa
Use CheckoutMaterializationFilter as the sole checkout source.
hahn-kev Jul 16, 2026
bdbb0a7
Add branch merge with interleaved rematerialization.
hahn-kev Jul 16, 2026
b7e3ff6
Query incorporated branches by change type and slice the merge apply …
hahn-kev Jul 16, 2026
996eb04
Add tags with checkout, move, and roll-forward notifications.
hahn-kev Jul 16, 2026
c8b3f98
Scope change-type entity ids to the tip and simplify tag checkout config
hahn-kev Jul 16, 2026
466940a
Add commit-author interceptor for transparent branch assignment
hahn-kev Jul 16, 2026
03a4904
Make branch-assignment marker transient and support multiple intercep…
hahn-kev Jul 16, 2026
a551514
Add post-apply listener for transparent tag roll-forward
hahn-kev Jul 16, 2026
a115e60
Slim RefsDataModel to checkout and ref lifecycle only.
hahn-kev Jul 16, 2026
85ff0c5
Add CreateBranch and List*; dedupe tag tip resolution
hahn-kev Jul 16, 2026
8a57cfd
Update refs tests to create branches via RefsDataModel.
hahn-kev Jul 16, 2026
0a5329d
Reorganize refs tests into dedicated folder
hahn-kev Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,7 @@ FodyWeavers.xsd
*.received.*

*.lscache

# Agent skills (local-only)
.scratch/
docs/agents/
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Agent skills

### Issue tracker

Issues live as local markdown files under `.scratch/` (gitignored). See `docs/agents/issue-tracker.md`.

### Triage labels

Default triage role strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.

### Domain docs

Single-context layout — `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
6 changes: 6 additions & 0 deletions harmony.sln
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Tests", "src\SI
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Linq2db", "src\SIL.Harmony.Linq2db\SIL.Harmony.Linq2db.csproj", "{8EB807F6-C548-4016-856E-2ACCA5603036}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Refs", "src\SIL.Harmony.Refs\SIL.Harmony.Refs.csproj", "{C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{321635F9-11D9-4B45-A2F8-F3B70E9A4196}"
ProjectSection(SolutionItems) = preProject
src\Directory.Build.props = src\Directory.Build.props
Expand Down Expand Up @@ -55,5 +57,9 @@ Global
{8EB807F6-C548-4016-856E-2ACCA5603036}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8EB807F6-C548-4016-856E-2ACCA5603036}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8EB807F6-C548-4016-856E-2ACCA5603036}.Release|Any CPU.Build.0 = Release|Any CPU
{C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
32 changes: 32 additions & 0 deletions src/SIL.Harmony.Refs/BranchAssignment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace SIL.Harmony.Refs;

/// <summary>
/// Where a newly authored commit should be assigned.
/// </summary>
public readonly record struct BranchAssignment
{
private BranchAssignment(BranchAssignmentKind kind, Guid? branchId)
{
Kind = kind;
BranchId = branchId;
}

public BranchAssignmentKind Kind { get; }
public Guid? BranchId { get; }

/// <summary>Use the current local checkout (default).</summary>
public static BranchAssignment FromCheckout { get; } = new(BranchAssignmentKind.FromCheckout, null);

/// <summary>Force main line (no branch metadata).</summary>
public static BranchAssignment Main { get; } = new(BranchAssignmentKind.Main, null);

/// <summary>Force a specific branch.</summary>
public static BranchAssignment ToBranch(Guid branchId) => new(BranchAssignmentKind.Branch, branchId);
}

public enum BranchAssignmentKind
{
FromCheckout,
Main,
Branch
}
21 changes: 21 additions & 0 deletions src/SIL.Harmony.Refs/Changes/CreateBranchChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using SIL.Harmony.Changes;
using SIL.Harmony.Entities;
using SIL.Harmony.Refs.Entities;

namespace SIL.Harmony.Refs.Changes;

public class CreateBranchChange(Guid entityId, string name) : CreateChange<Branch>(entityId), IPolyType
{
public string Name { get; } = name;

public override ValueTask<Branch> NewEntity(Commit commit, IChangeContext context)
{
return ValueTask.FromResult(new Branch
{
Id = EntityId,
Name = Name
});
}

public static string TypeName => "create:branch";
}
23 changes: 23 additions & 0 deletions src/SIL.Harmony.Refs/Changes/CreateTagChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using SIL.Harmony.Changes;
using SIL.Harmony.Entities;
using SIL.Harmony.Refs.Entities;

namespace SIL.Harmony.Refs.Changes;

public class CreateTagChange(Guid entityId, string name, Guid targetCommitId) : CreateChange<Tag>(entityId), IPolyType
{
public string Name { get; } = name;
public Guid TargetCommitId { get; } = targetCommitId;

public override ValueTask<Tag> NewEntity(Commit commit, IChangeContext context)
{
return ValueTask.FromResult(new Tag
{
Id = EntityId,
Name = Name,
TargetCommitId = TargetCommitId
});
}

public static string TypeName => "create:tag";
}
20 changes: 20 additions & 0 deletions src/SIL.Harmony.Refs/Changes/MergeBranchChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using SIL.Harmony.Changes;
using SIL.Harmony.Entities;
using SIL.Harmony.Refs.Entities;

namespace SIL.Harmony.Refs.Changes;

/// <summary>
/// Main-line commit that incorporates a branch into main visibility and deletes the branch entity.
/// Does not rewrite branch commit payloads; materialization starts including those commits after this change.
/// </summary>
public class MergeBranchChange(Guid entityId) : EditChange<Branch>(entityId), IPolyType
{
public override ValueTask ApplyChange(Branch entity, IChangeContext context)
{
entity.DeletedAt = context.Commit.DateTime;
return ValueTask.CompletedTask;
}

public static string TypeName => "merge:branch";
}
18 changes: 18 additions & 0 deletions src/SIL.Harmony.Refs/Changes/MoveTagChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using SIL.Harmony.Changes;
using SIL.Harmony.Entities;
using SIL.Harmony.Refs.Entities;

namespace SIL.Harmony.Refs.Changes;

public class MoveTagChange(Guid entityId, Guid targetCommitId) : EditChange<Tag>(entityId), IPolyType
{
public Guid TargetCommitId { get; } = targetCommitId;

public override ValueTask ApplyChange(Tag entity, IChangeContext context)
{
entity.TargetCommitId = TargetCommitId;
return ValueTask.CompletedTask;
}

public static string TypeName => "move:tag";
}
107 changes: 107 additions & 0 deletions src/SIL.Harmony.Refs/CheckoutMaterializationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using SIL.Harmony.Db;
using SIL.Harmony.Refs.Changes;

namespace SIL.Harmony.Refs;

/// <summary>
/// Materializes commits visible for the current local checkout:
/// main line (no branch id), commits for incorporated (merged) branches on main,
/// plus commits scoped to the checked-out branch when on a branch,
/// or commits at-or-before a tag tip with visibility evaluated at that tip.
/// </summary>
public sealed class CheckoutMaterializationFilter : ICommitMaterializationFilter, IMaterializationApplyWindow
{
private readonly HashSet<Guid> _incorporatedBranchIds = [];
private Commit? _asOfTip;

public RefCheckout Checkout { get; set; } = RefCheckout.Main;

public bool HasAsOfTip => _asOfTip is not null;

/// <summary>
/// The commit the current tag checkout is pinned to, or null when not pinned to a tip.
/// Lets the roll-forward listener skip rematerialization when the tag tip has not moved.
/// </summary>
public Guid? AsOfTipId => _asOfTip?.Id;

/// <summary>
/// When set (tag checkout), only commits at or before this tip are included,
/// and merge incorporation is evaluated only through this tip.
/// </summary>
public void SetAsOfTip(Commit tip) => _asOfTip = tip;

public void ClearAsOfTip() => _asOfTip = null;

public bool Include(Commit commit)
{
// Keep the active tag entity materializable even when create/move commits are after the tip.
if (Checkout is TagCheckout tag && TouchesTag(commit, tag.TagId))
return true;

if (_asOfTip is { } tip && commit.CompareKey.CompareTo(tip.CompareKey) > 0)
return false;

var branchId = RefMetadata.GetBranchId(commit.Metadata);
if (branchId is null) return true;

return Checkout switch
{
BranchCheckout branch => branchId == branch.BranchId || _incorporatedBranchIds.Contains(branchId.Value),
TagCheckout when _asOfTip is { } asOfTip && RefMetadata.GetBranchId(asOfTip.Metadata) is Guid tipBranch =>
branchId == tipBranch || _incorporatedBranchIds.Contains(branchId.Value),
_ => _incorporatedBranchIds.Contains(branchId.Value)
};
}

async Task<SortedSet<Commit>> IMaterializationApplyWindow.PrepareApplyWindowAsync(
CrdtRepository repo,
SortedSet<Commit> commitsToApply)
{
await RefreshIncorporatedBranchIds(repo);

var mergedInWindow = commitsToApply
.SelectMany(MergesIn)
.Select(m => m.EntityId)
.ToHashSet();
if (mergedInWindow.Count == 0) return commitsToApply;

// CurrentCommits is DefaultOrder (oldest first). First hit is the window start:
// earliest merged-branch commit, or the apply-window head if that is older.
var applyMinId = commitsToApply.Min!.Id;
var allCommits = await repo.CurrentCommits()
.Include(c => c.ChangeEntities)
.ToListAsync();

for (var i = 0; i < allCommits.Count; i++)
{
var commit = allCommits[i];
var branchId = RefMetadata.GetBranchId(commit.Metadata);
if (commit.Id == applyMinId ||
(branchId is Guid id && mergedInWindow.Contains(id)))
{
return allCommits.Skip(i).ToSortedSet();
}
}

return commitsToApply;
}

private async Task RefreshIncorporatedBranchIds(CrdtRepository repo)
{
_incorporatedBranchIds.Clear();
// Scoping to the tip bounds GetEntityIdsForChangeType to merges at or before it (see issue 15),
// so a single query serves both the tag (as-of) and unscoped cases.
if (_asOfTip is { } tip) repo = repo.GetScopedRepository(tip);
_incorporatedBranchIds.UnionWith(await repo.GetEntityIdsForChangeType<MergeBranchChange>());
}

private static IEnumerable<MergeBranchChange> MergesIn(Commit commit) =>
commit.ChangeEntities
.Select(ce => ce.Change)
.OfType<MergeBranchChange>();

private static bool TouchesTag(Commit commit, Guid tagId) =>
commit.ChangeEntities.Any(ce => ce.EntityId == tagId &&
(ce.Change is CreateTagChange or MoveTagChange));
}
58 changes: 58 additions & 0 deletions src/SIL.Harmony.Refs/CheckoutRefsHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace SIL.Harmony.Refs;

/// <summary>
/// The refs bridge into <see cref="DataModel"/>'s generic extension points, so clients can author
/// and sync through <see cref="DataModel"/> directly and still get branch/tag behaviour without the
/// <see cref="RefsDataModel"/> wrapper.
/// <list type="bullet">
/// <item>As an <see cref="ICommitInterceptor"/> it stamps the current checkout's branch assignment
/// on locally-authored commits (or rejects authoring on a tag).</item>
/// <item>As an <see cref="ICommitAppliedListener"/> it rolls an active tag checkout forward after
/// any apply — local or synced — when the tag's tip moved.</item>
/// </list>
/// <see cref="DataModel"/> is resolved lazily (only when rolling forward) because the roll-forward
/// depends on it, yet it is already constructed by the time an apply completes; requiring it in the
/// constructor would form a cycle, since this same instance is also the constructor-injected interceptor.
/// </summary>
public sealed class CheckoutRefsHandler(
CheckoutMaterializationFilter filter,
IOptions<CrdtConfig> config,
IServiceProvider serviceProvider)
: ICommitInterceptor, ICommitAppliedListener
{
public void OnCommitAuthored(Commit commit)
{
// Explicit per-call override or a ref-lifecycle assignment already decided this commit.
// ConsumeAssignment also clears the transient marker so it is never persisted or synced.
if (RefMetadata.ConsumeAssignment(commit.Metadata)) return;

switch (filter.Checkout)
{
case BranchCheckout branch:
RefMetadata.SetBranchId(commit.Metadata, branch.BranchId);
break;
case TagCheckout when !config.Value.AllowAuthoringOnTagToMain:
throw new InvalidOperationException(
"Authoring is not allowed while checked out on a tag. Set AllowAuthoringOnTagToMain to write to main, "
+ "author with an explicit BranchAssignment, or checkout main/branch first.");
// MainCheckout, or a tag checkout with AllowAuthoringOnTagToMain: author to main (no branch id).
}
}

public async Task OnCommitsAppliedAsync(IReadOnlyCollection<Commit> commits)
{
// Roll-forward only concerns tag checkouts; a main/branch view never moves on apply.
if (filter.Checkout is not TagCheckout tag) return;

var dataModel = serviceProvider.GetRequiredService<DataModel>();
var tip = await TagTipResolver.ResolveTagTip(dataModel, tag.TagId);
// Skip rematerialization unless the tag's tip actually moved.
if (filter.AsOfTipId == tip.Id) return;

filter.SetAsOfTip(tip);
await dataModel.RegenerateSnapshots();
}
}
26 changes: 26 additions & 0 deletions src/SIL.Harmony.Refs/Entities/Branch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using SIL.Harmony.Entities;

namespace SIL.Harmony.Refs.Entities;

/// <summary>
/// A named line of work. Display <see cref="Name"/> is not unique; identity is <see cref="Id"/>.
/// </summary>
public class Branch : IObjectBase<Branch>
{
public Guid Id { get; init; }
public required string Name { get; set; }
public DateTimeOffset? DeletedAt { get; set; }

public Guid[] GetReferences() => [];

public void RemoveReference(Guid id, CommitBase commit)
{
}

public IObjectBase Copy() => new Branch
{
Id = Id,
Name = Name,
DeletedAt = DeletedAt
};
}
31 changes: 31 additions & 0 deletions src/SIL.Harmony.Refs/Entities/Tag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using SIL.Harmony.Entities;

namespace SIL.Harmony.Refs.Entities;

/// <summary>
/// A named movable pointer to a commit. Display <see cref="Name"/> is not unique; identity is <see cref="Id"/>.
/// Polymorphic type name is <c>harmony:tag</c> to avoid colliding with app-domain tag types.
/// </summary>
public class Tag : IObjectBase<Tag>
{
static string IPolyType.TypeName => "harmony:tag";

public Guid Id { get; init; }
public required string Name { get; set; }
public Guid TargetCommitId { get; set; }
public DateTimeOffset? DeletedAt { get; set; }

public Guid[] GetReferences() => [];

public void RemoveReference(Guid id, CommitBase commit)
{
}

public IObjectBase Copy() => new Tag
{
Id = Id,
Name = Name,
TargetCommitId = TargetCommitId,
DeletedAt = DeletedAt
};
}
Loading
Loading