diff --git a/src/Polyphony/Branching/BranchEnsurer.cs b/src/Polyphony/Branching/BranchEnsurer.cs
new file mode 100644
index 0000000..4dd68de
--- /dev/null
+++ b/src/Polyphony/Branching/BranchEnsurer.cs
@@ -0,0 +1,286 @@
+using System.Text.RegularExpressions;
+using Polyphony.Infrastructure.Processes;
+
+namespace Polyphony.Branching;
+
+///
+/// Shared idempotent branch-ensure matrix used by every
+/// BranchCommands.Ensure* verb (feature, plan, impl, merge-group,
+/// evidence). Each verb owns its own input validation, journal envelope,
+/// per-kind error guidance, and result-envelope projection; the matrix
+/// itself — ls-remote / rev-parse / checkout / fetch+track / create+push
+/// — lives here so the five verbs never diverge.
+///
+/// This is a behavior-preserving refactor (AB#3312). The git
+/// invocation sequence for each branch of the matrix matches the
+/// pre-refactor verbs exactly. The five existing per-verb test files
+/// stub IGitClient via FakeProcessRunner and act as the
+/// safety net for that promise.
+///
+public sealed partial class BranchEnsurer(IGitClient git)
+{
+ ///
+ /// Matches git's "fatal: '<branch>' is already used by worktree at
+ /// '<path>'" stderr (AB#211). Single-quoted on POSIX; git emits
+ /// the same quoting on Windows. Captures the worktree path so the
+ /// ensurer can surface it in the success outcome.
+ ///
+ [GeneratedRegex(
+ @"is already used by worktree at '([^']+)'",
+ RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
+ private static partial Regex BranchInOtherWorktreeRegex();
+
+ ///
+ /// Idempotently ensure .Target exists locally
+ /// and on the remote. Returns either
+ /// with a populated outcome, or
+ /// when the base branch is required but absent. Per-kind error hint
+ /// text is the verb's responsibility — this method reports the
+ /// failure shape, not the user-facing message.
+ ///
+ public async Task EnsureAsync(BranchSpec spec, CancellationToken ct)
+ {
+ // 1. Probe remote + local existence of the target branch.
+ var remoteRefs = await git.LsRemoteHeadsAsync(spec.Remote, spec.Target, ct).ConfigureAwait(false);
+ var remoteExisted = remoteRefs.Count > 0;
+
+ var localSha = await git.RevParseLocalBranchAsync(spec.Target, ct).ConfigureAwait(false);
+ var localExisted = localSha is not null;
+ var currentBranch = localExisted
+ ? await TryGetCurrentBranchAsync(ct).ConfigureAwait(false)
+ : null;
+
+ string action;
+ bool pushed = false;
+ string? createdFrom = null;
+ string? worktreePath = null;
+ bool baseRemoteExisted = false;
+ bool baseFetched = false;
+ bool wasMutated;
+
+ if (localExisted)
+ {
+ // Local exists — check it out. Worktree-conflict tolerance
+ // (AB#211) is opt-in via spec.TolerateWorktreeConflict; other
+ // kinds let the exception propagate to the verb's catch block.
+ try
+ {
+ await git.CheckoutAsync(spec.Target, ct).ConfigureAwait(false);
+ action = "checked_out";
+ wasMutated = currentBranch is null
+ || !string.Equals(currentBranch, spec.Target, StringComparison.Ordinal);
+ }
+ catch (ExternalToolException ex)
+ when (spec.TolerateWorktreeConflict
+ && BranchInOtherWorktreeRegex().Match(ex.Stderr) is { Success: true } match)
+ {
+ worktreePath = match.Groups[1].Value;
+ action = "exists_in_other_worktree";
+ wasMutated = false;
+ }
+
+ if (!remoteExisted)
+ {
+ // Push so downstream steps can branch from it. Push works
+ // regardless of which worktree owns the checkout — git
+ // resolves refs/heads/{branch} by ref, not by working tree.
+ await git.PushAsync(spec.Target, spec.Remote, ct).ConfigureAwait(false);
+ pushed = true;
+ wasMutated = true;
+ }
+
+ if (spec.IncludeBaseOnRemoteCheck)
+ {
+ baseRemoteExisted = await BaseExistsOnRemoteAsync(spec.Base!, spec.Remote, ct).ConfigureAwait(false);
+ }
+ }
+ else if (remoteExisted)
+ {
+ // Remote exists, local doesn't — fetch + track.
+ await git.FetchAsync(spec.Remote, spec.Target, ct).ConfigureAwait(false);
+ await git.CheckoutTrackingAsync(spec.Target, spec.Remote, ct).ConfigureAwait(false);
+ action = "checked_out";
+ wasMutated = true;
+
+ if (spec.IncludeBaseOnRemoteCheck)
+ {
+ baseRemoteExisted = await BaseExistsOnRemoteAsync(spec.Base!, spec.Remote, ct).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ // Neither exists — materialize from base.
+ if (spec.IncludeBaseOnRemoteCheck)
+ {
+ baseRemoteExisted = await BaseExistsOnRemoteAsync(spec.Base!, spec.Remote, ct).ConfigureAwait(false);
+ if (!baseRemoteExisted)
+ {
+ return BranchEnsureOutcome.BaseMissing(spec.Base!, spec.Remote);
+ }
+
+ // If the base isn't local, fetch and check it out so the
+ // create-from-base step has a known local start point.
+ var baseLocalSha = await git.RevParseLocalBranchAsync(spec.Base!, ct).ConfigureAwait(false);
+ if (baseLocalSha is null)
+ {
+ await git.FetchAsync(spec.Remote, spec.Base!, ct).ConfigureAwait(false);
+ await git.CheckoutTrackingAsync(spec.Base!, spec.Remote, ct).ConfigureAwait(false);
+ baseFetched = true;
+ }
+ }
+
+ await git.CreateBranchAsync(spec.Target, spec.Base, ct).ConfigureAwait(false);
+ await git.PushAsync(spec.Target, spec.Remote, ct).ConfigureAwait(false);
+ action = "created";
+ pushed = true;
+ createdFrom = spec.Base;
+ wasMutated = true;
+ }
+
+ return BranchEnsureOutcome.Success(
+ action: action,
+ remoteExisted: remoteExisted,
+ pushed: pushed,
+ createdFrom: createdFrom,
+ worktreePath: worktreePath,
+ baseRemoteExisted: baseRemoteExisted,
+ baseFetched: baseFetched,
+ wasMutated: wasMutated);
+ }
+
+ ///
+ /// Returns the SHA of via
+ /// git rev-parse --verify refs/heads/{branch}, or null if the
+ /// rev-parse fails for any reason. Convenience helper exposed for
+ /// verbs that populate Payload.Sha after the matrix completes.
+ ///
+ public async Task TryGetBranchShaAsync(string branch, CancellationToken ct)
+ {
+ try
+ {
+ return await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private async Task TryGetCurrentBranchAsync(CancellationToken ct)
+ {
+ try
+ {
+ return await git.GetCurrentBranchAsync(ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private async Task BaseExistsOnRemoteAsync(string baseBranch, string remote, CancellationToken ct)
+ {
+ var refs = await git.LsRemoteHeadsAsync(remote, baseBranch, ct).ConfigureAwait(false);
+ return refs.Count > 0;
+ }
+}
+
+///
+/// Inputs to .
+///
+/// Branch to ensure exists locally + remote.
+/// Branch to create from
+/// when neither local nor remote copy of
+/// exists. Required when
+/// is true; may be the literal trusted value (e.g. main) when
+/// the caller opts out of the remote check.
+/// Git remote name (default origin).
+/// When true, treat
+/// "branch already used by another worktree" as a non-failure
+/// outcome and surface the worktree path. Used by the feature-branch
+/// verb (AB#211); other verbs throw on this condition.
+/// When true, the matrix
+/// validates that exists on the remote
+/// before creating from it AND populates
+/// BaseRemoteExisted on the outcome. When false the matrix
+/// trusts exists (feature-branch behavior;
+/// base is main).
+public sealed record BranchSpec(
+ string Target,
+ string? Base,
+ string Remote,
+ bool TolerateWorktreeConflict = false,
+ bool IncludeBaseOnRemoteCheck = true);
+
+///
+/// Outcome of . Use the static
+/// factories and rather
+/// than constructing directly — the success/failure shapes have
+/// mutually-exclusive fields and the factories prevent invalid states.
+///
+public sealed record BranchEnsureOutcome
+{
+ public BranchEnsureStatus Status { get; init; }
+
+ /// "created", "checked_out", "exists_in_other_worktree", or "error".
+ public string Action { get; init; } = "error";
+
+ public bool RemoteExisted { get; init; }
+ public bool Pushed { get; init; }
+ public string? CreatedFrom { get; init; }
+
+ /// Populated only when is
+ /// exists_in_other_worktree.
+ public string? WorktreePath { get; init; }
+
+ public bool BaseRemoteExisted { get; init; }
+ public bool BaseFetched { get; init; }
+ public bool WasMutated { get; init; }
+
+ /// Echoed back from when
+ /// is .
+ public string? MissingBase { get; init; }
+
+ /// Echoed back from when
+ /// is .
+ public string? MissingBaseRemote { get; init; }
+
+ private BranchEnsureOutcome() { }
+
+ public static BranchEnsureOutcome Success(
+ string action,
+ bool remoteExisted,
+ bool pushed,
+ string? createdFrom,
+ string? worktreePath,
+ bool baseRemoteExisted,
+ bool baseFetched,
+ bool wasMutated)
+ => new()
+ {
+ Status = BranchEnsureStatus.Success,
+ Action = action,
+ RemoteExisted = remoteExisted,
+ Pushed = pushed,
+ CreatedFrom = createdFrom,
+ WorktreePath = worktreePath,
+ BaseRemoteExisted = baseRemoteExisted,
+ BaseFetched = baseFetched,
+ WasMutated = wasMutated,
+ };
+
+ public static BranchEnsureOutcome BaseMissing(string baseBranch, string remote)
+ => new()
+ {
+ Status = BranchEnsureStatus.BaseMissingOnRemote,
+ Action = "error",
+ MissingBase = baseBranch,
+ MissingBaseRemote = remote,
+ };
+}
+
+public enum BranchEnsureStatus
+{
+ Success,
+ BaseMissingOnRemote,
+}
diff --git a/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs b/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs
index 6cc7dfb..be250e3 100644
--- a/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs
+++ b/src/Polyphony/Commands/BranchCommands.EnsureEvidenceBranch.cs
@@ -2,7 +2,6 @@
using ConsoleAppFramework;
using Polyphony.Annotations;
using Polyphony.Branching;
-using Polyphony.Infrastructure.Processes;
using Polyphony.Journal;
using Polyphony.Journal.Payloads;
@@ -84,114 +83,52 @@ public async Task EnsureEvidenceBranch(
{
try
{
- // ── 3. Inspect current state. ────────────────────────────────
- var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false);
- var remoteExisted = remoteRefs.Count > 0;
+ var outcome = await _branchEnsurer.EnsureAsync(
+ new BranchSpec(branch, baseBranch, remote),
+ innerCt).ConfigureAwait(false);
- var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false);
- var localExisted = localSha is not null;
- var currentBranch = localExisted
- ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false)
- : null;
-
- string action;
- bool pushed = false;
- string? createdFrom = null;
- bool baseRemoteExisted;
- bool baseFetched = false;
- bool wasMutated;
-
- if (localExisted)
- {
- await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false);
- action = "checked_out";
- wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal);
-
- if (!remoteExisted)
- {
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- pushed = true;
- wasMutated = true;
- }
-
- // Base is irrelevant when the target already exists locally,
- // but we still report whether it's on the remote so the
- // workflow can distinguish "evidence exists, but the root
- // feature has been deleted" from a fully wired state.
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- }
- else if (remoteExisted)
- {
- await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "checked_out";
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- wasMutated = true;
- }
- else
+ if (outcome.Status == BranchEnsureStatus.BaseMissingOnRemote)
{
- // Need to materialize from base. Confirm it exists on remote first.
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- if (!baseRemoteExisted)
+ payload = new BranchEnsureEvidenceBranchPayload
{
- payload = new BranchEnsureEvidenceBranchPayload
- {
- RootId = resolvedRootId,
- WorkItemId = workItemId,
- BranchName = branch,
- BaseBranch = baseBranch,
- ResultAction = "error",
- Succeeded = false,
- WasMutated = false,
- WasCreated = false,
- WasPushed = false,
- BaseFetched = false,
- Orphan = orphan,
- FromRef = fromRef,
- Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. " +
- (string.IsNullOrEmpty(fromRef)
- ? $"Run 'polyphony branch ensure-feature' for root {resolvedRootId} first, or pass --from-ref to base evidence on a different branch."
- : "Verify the --from-ref value points at a branch that exists on the remote."),
- };
- EmitEvidenceError(
- workItemId,
- rootId,
- fromRef,
- payload.Error,
- branch: branch,
- baseBranch: baseBranch,
- orphan: orphan);
- return ExitCodes.RoutingFailure;
- }
-
- // If the base isn't local, fetch and check it out so the
- // create-from-base step has a known local start point.
- var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false);
- if (baseLocalSha is null)
- {
- await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- baseFetched = true;
- }
-
- await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false);
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "created";
- pushed = true;
- createdFrom = baseBranch;
- wasMutated = true;
+ RootId = resolvedRootId,
+ WorkItemId = workItemId,
+ BranchName = branch,
+ BaseBranch = baseBranch,
+ ResultAction = "error",
+ Succeeded = false,
+ WasMutated = false,
+ WasCreated = false,
+ WasPushed = false,
+ BaseFetched = false,
+ Orphan = orphan,
+ FromRef = fromRef,
+ Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. " +
+ (string.IsNullOrEmpty(fromRef)
+ ? $"Run 'polyphony branch ensure-feature' for root {resolvedRootId} first, or pass --from-ref to base evidence on a different branch."
+ : "Verify the --from-ref value points at a branch that exists on the remote."),
+ };
+ EmitEvidenceError(
+ workItemId,
+ rootId,
+ fromRef,
+ payload.Error,
+ branch: branch,
+ baseBranch: baseBranch,
+ orphan: orphan);
+ return ExitCodes.RoutingFailure;
}
var result = new BranchEnsureEvidenceResult
{
Branch = branch,
BaseBranch = baseBranch,
- Action = action,
- RemoteExisted = remoteExisted,
- Pushed = pushed,
- BaseRemoteExisted = baseRemoteExisted,
- BaseFetched = baseFetched,
- CreatedFrom = createdFrom,
+ Action = outcome.Action,
+ RemoteExisted = outcome.RemoteExisted,
+ Pushed = outcome.Pushed,
+ BaseRemoteExisted = outcome.BaseRemoteExisted,
+ BaseFetched = outcome.BaseFetched,
+ CreatedFrom = outcome.CreatedFrom,
RootId = resolvedRootId,
ItemId = workItemId,
Orphan = orphan,
@@ -203,15 +140,15 @@ public async Task EnsureEvidenceBranch(
WorkItemId = workItemId,
BranchName = branch,
BaseBranch = baseBranch,
- ResultAction = action,
+ ResultAction = outcome.Action,
Succeeded = true,
- WasMutated = wasMutated,
- WasCreated = string.Equals(action, "created", StringComparison.Ordinal),
- WasPushed = pushed,
- BaseFetched = baseFetched,
+ WasMutated = outcome.WasMutated,
+ WasCreated = string.Equals(outcome.Action, "created", StringComparison.Ordinal),
+ WasPushed = outcome.Pushed,
+ BaseFetched = outcome.BaseFetched,
Orphan = orphan,
FromRef = fromRef,
- Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
+ Sha = await _branchEnsurer.TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
};
EmitEvidence(result);
return ExitCodes.Success;
diff --git a/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs b/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs
index 86765e6..411faba 100644
--- a/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs
+++ b/src/Polyphony/Commands/BranchCommands.EnsureFeature.cs
@@ -1,8 +1,7 @@
using System.Text.Json;
-using System.Text.RegularExpressions;
using ConsoleAppFramework;
using Polyphony.Annotations;
-using Polyphony.Infrastructure.Processes;
+using Polyphony.Branching;
using Polyphony.Journal;
using Polyphony.Journal.Payloads;
@@ -10,17 +9,6 @@ namespace Polyphony.Commands;
public sealed partial class BranchCommands
{
- ///
- /// Matches git's "fatal: '<branch>' is already used by worktree at
- /// '<path>'" stderr (AB#211). Single-quoted on POSIX; git emits
- /// the same quoting on Windows. Captures the worktree path so the
- /// verb can surface it in the success envelope.
- ///
- [GeneratedRegex(
- @"is already used by worktree at '([^']+)'",
- RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
- private static partial Regex BranchInOtherWorktreeRegex();
-
///
/// Idempotently ensure a feature branch exists locally and on the remote.
/// Creates from if absent. The root workflow
@@ -54,85 +42,26 @@ public async Task EnsureFeature(
{
try
{
- // 1. Check if the branch exists on the remote.
- var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false);
- var remoteExisted = remoteRefs.Count > 0;
-
- // 2. Check if it exists locally.
- var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false);
- var localExisted = localSha is not null;
- var currentBranch = localExisted
- ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false)
- : null;
-
- string action;
- bool pushed = false;
- string? createdFrom = null;
- string? worktreePath = null;
- bool wasMutated;
-
- if (localExisted)
- {
- // Local branch exists — try to check it out in the current
- // worktree. Under the parallel-fleet root convention the
- // branch may already be checked out in a sibling worktree;
- // git refuses with exit 128 + "is already used by worktree
- // at '...'". That is NOT a failure of this verb's purpose
- // (the branch DOES exist locally); treat it as a success
- // and surface the sibling worktree path so the workflow
- // can route to it (AB#211).
- try
- {
- await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false);
- action = "checked_out";
- wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal);
- }
- catch (ExternalToolException ex)
- when (BranchInOtherWorktreeRegex().Match(ex.Stderr) is { Success: true } worktreeMatch)
- {
- worktreePath = worktreeMatch.Groups[1].Value;
- action = "exists_in_other_worktree";
- wasMutated = false;
- }
-
- if (!remoteExisted)
- {
- // Push to remote so downstream steps can branch from
- // it. Push works regardless of which worktree owns
- // the checkout — git resolves refs/heads/{branch} by
- // ref, not by working tree.
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- pushed = true;
- wasMutated = true;
- }
- }
- else if (remoteExisted)
- {
- // Remote exists but not local — fetch and create tracking branch.
- await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "checked_out";
- wasMutated = true;
- }
- else
- {
- // Neither local nor remote — create from base branch.
- await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false);
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "created";
- pushed = true;
- createdFrom = baseBranch;
- wasMutated = true;
- }
+ // Feature branches tolerate the AB#211 sibling-worktree
+ // case and trust the base branch (main) without an
+ // ls-remote probe. See BranchEnsurer for the matrix.
+ var outcome = await _branchEnsurer.EnsureAsync(
+ new BranchSpec(
+ Target: branch,
+ Base: baseBranch,
+ Remote: remote,
+ TolerateWorktreeConflict: true,
+ IncludeBaseOnRemoteCheck: false),
+ innerCt).ConfigureAwait(false);
var result = new BranchEnsureFeatureResult
{
Branch = branch,
- Action = action,
- RemoteExisted = remoteExisted,
- Pushed = pushed,
- CreatedFrom = createdFrom,
- WorktreePath = worktreePath,
+ Action = outcome.Action,
+ RemoteExisted = outcome.RemoteExisted,
+ Pushed = outcome.Pushed,
+ CreatedFrom = outcome.CreatedFrom,
+ WorktreePath = outcome.WorktreePath,
};
payload = new BranchEnsureFeaturePayload
{
@@ -140,13 +69,13 @@ public async Task EnsureFeature(
WorkItemId = parsedRootId,
BranchName = branch,
BaseBranch = baseBranch,
- ResultAction = action,
+ ResultAction = outcome.Action,
Succeeded = true,
- WasMutated = wasMutated,
- WasCreated = string.Equals(action, "created", StringComparison.Ordinal),
- WasPushed = pushed,
- WorktreePath = worktreePath,
- Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
+ WasMutated = outcome.WasMutated,
+ WasCreated = string.Equals(outcome.Action, "created", StringComparison.Ordinal),
+ WasPushed = outcome.Pushed,
+ WorktreePath = outcome.WorktreePath,
+ Sha = await _branchEnsurer.TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
};
Console.WriteLine(JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.BranchEnsureFeatureResult));
return ExitCodes.Success;
@@ -191,4 +120,3 @@ public async Task EnsureFeature(
ct: ct).ConfigureAwait(false);
}
}
-
diff --git a/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs b/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs
index 1711f13..1b5bddd 100644
--- a/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs
+++ b/src/Polyphony/Commands/BranchCommands.EnsureImpl.cs
@@ -2,7 +2,6 @@
using ConsoleAppFramework;
using Polyphony.Annotations;
using Polyphony.Branching;
-using Polyphony.Infrastructure.Processes;
using Polyphony.Journal;
using Polyphony.Journal.Payloads;
@@ -82,101 +81,47 @@ public async Task EnsureImpl(
{
try
{
- var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false);
- var remoteExisted = remoteRefs.Count > 0;
+ var outcome = await _branchEnsurer.EnsureAsync(
+ new BranchSpec(branch, baseBranch, remote),
+ innerCt).ConfigureAwait(false);
- var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false);
- var localExisted = localSha is not null;
- var currentBranch = localExisted
- ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false)
- : null;
-
- string action;
- bool pushed = false;
- string? createdFrom = null;
- bool baseRemoteExisted;
- bool baseFetched = false;
- bool wasMutated;
-
- if (localExisted)
- {
- await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false);
- action = "checked_out";
- wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal);
-
- if (!remoteExisted)
- {
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- pushed = true;
- wasMutated = true;
- }
-
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- }
- else if (remoteExisted)
- {
- await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "checked_out";
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- wasMutated = true;
- }
- else
+ if (outcome.Status == BranchEnsureStatus.BaseMissingOnRemote)
{
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- if (!baseRemoteExisted)
+ payload = new BranchEnsureImplPayload
{
- payload = new BranchEnsureImplPayload
- {
- RootId = rootId,
- WorkItemId = itemId,
- MergeGroupPath = path.Canonical,
- BranchName = branch,
- BaseBranch = baseBranch,
- ResultAction = "error",
- Succeeded = false,
- WasMutated = false,
- WasCreated = false,
- WasPushed = false,
- BaseFetched = false,
- Error = $"base merge-group branch '{baseBranch}' does not exist on remote '{remote}'. Run 'polyphony branch ensure-mg' for this path first.",
- };
- EmitImplError(
- rootId,
- itemId,
- mgPath,
- payload.Error,
- branch: branch,
- baseBranch: baseBranch);
- return ExitCodes.RoutingFailure;
- }
-
- var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false);
- if (baseLocalSha is null)
- {
- await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- baseFetched = true;
- }
-
- await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false);
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "created";
- pushed = true;
- createdFrom = baseBranch;
- wasMutated = true;
+ RootId = rootId,
+ WorkItemId = itemId,
+ MergeGroupPath = path.Canonical,
+ BranchName = branch,
+ BaseBranch = baseBranch,
+ ResultAction = "error",
+ Succeeded = false,
+ WasMutated = false,
+ WasCreated = false,
+ WasPushed = false,
+ BaseFetched = false,
+ Error = $"base merge-group branch '{baseBranch}' does not exist on remote '{remote}'. Run 'polyphony branch ensure-mg' for this path first.",
+ };
+ EmitImplError(
+ rootId,
+ itemId,
+ mgPath,
+ payload.Error,
+ branch: branch,
+ baseBranch: baseBranch);
+ return ExitCodes.RoutingFailure;
}
var result = new BranchEnsureImplResult
{
Branch = branch,
BaseBranch = baseBranch,
- Action = action,
- RemoteExisted = remoteExisted,
- Pushed = pushed,
- BaseRemoteExisted = baseRemoteExisted,
- BaseFetched = baseFetched,
- CreatedFrom = createdFrom,
+ Action = outcome.Action,
+ RemoteExisted = outcome.RemoteExisted,
+ Pushed = outcome.Pushed,
+ BaseRemoteExisted = outcome.BaseRemoteExisted,
+ BaseFetched = outcome.BaseFetched,
+ CreatedFrom = outcome.CreatedFrom,
RootId = rootId,
ItemId = itemId,
MgPath = path.Canonical,
@@ -188,13 +133,13 @@ public async Task EnsureImpl(
MergeGroupPath = path.Canonical,
BranchName = branch,
BaseBranch = baseBranch,
- ResultAction = action,
+ ResultAction = outcome.Action,
Succeeded = true,
- WasMutated = wasMutated,
- WasCreated = string.Equals(action, "created", StringComparison.Ordinal),
- WasPushed = pushed,
- BaseFetched = baseFetched,
- Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
+ WasMutated = outcome.WasMutated,
+ WasCreated = string.Equals(outcome.Action, "created", StringComparison.Ordinal),
+ WasPushed = outcome.Pushed,
+ BaseFetched = outcome.BaseFetched,
+ Sha = await _branchEnsurer.TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
};
EmitImpl(result);
return ExitCodes.Success;
diff --git a/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs b/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs
index 2d74fcd..a5270c5 100644
--- a/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs
+++ b/src/Polyphony/Commands/BranchCommands.EnsureMergeGroup.cs
@@ -2,7 +2,6 @@
using ConsoleAppFramework;
using Polyphony.Annotations;
using Polyphony.Branching;
-using Polyphony.Infrastructure.Processes;
using Polyphony.Journal;
using Polyphony.Journal.Payloads;
@@ -77,114 +76,50 @@ public async Task EnsureMergeGroup(
{
try
{
- // ── 2. Check current state of MG branch on remote and locally. ─
- var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false);
- var remoteExisted = remoteRefs.Count > 0;
+ var outcome = await _branchEnsurer.EnsureAsync(
+ new BranchSpec(branch, baseBranch, remote),
+ innerCt).ConfigureAwait(false);
- var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false);
- var localExisted = localSha is not null;
- var currentBranch = localExisted
- ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false)
- : null;
-
- string action;
- bool pushed = false;
- string? createdFrom = null;
-
- // ── 3. Verify the base branch exists on the remote — if it
- // doesn't, child creation can't succeed. We check this
- // only when we'd actually need it (target branch missing
- // both locally and remotely). ─────────────────────────────
- bool baseRemoteExisted;
- bool baseFetched = false;
- bool wasMutated;
-
- if (localExisted)
- {
- await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false);
- action = "checked_out";
- wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal);
-
- if (!remoteExisted)
- {
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- pushed = true;
- wasMutated = true;
- }
-
- // Base is irrelevant when the target already exists.
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- }
- else if (remoteExisted)
+ if (outcome.Status == BranchEnsureStatus.BaseMissingOnRemote)
{
- await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "checked_out";
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- wasMutated = true;
- }
- else
- {
- // Need to materialize from base. Confirm base exists on remote first.
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- if (!baseRemoteExisted)
- {
- payload = new BranchEnsureMergeGroupPayload
- {
- RootId = rootId,
- MergeGroupPath = path.Canonical,
- Depth = path.Depth,
- BranchName = branch,
- BaseBranch = baseBranch,
- ResultAction = "error",
- Succeeded = false,
- WasMutated = false,
- WasCreated = false,
- WasPushed = false,
- BaseFetched = false,
- Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. " +
- (path.IsTopLevel
- ? "Run 'polyphony branch ensure-feature' first to create the feature branch."
- : "Run 'polyphony branch ensure-mg' for the parent path first."),
- };
- EmitMgError(
- rootId,
- mgPath,
- payload.Error,
- branch: branch,
- baseBranch: baseBranch,
- depth: path.Depth);
- return ExitCodes.RoutingFailure;
- }
-
- // If the base isn't local, fetch and check it out so the
- // create-from-base step has a known local start point.
- var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false);
- if (baseLocalSha is null)
+ payload = new BranchEnsureMergeGroupPayload
{
- await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- baseFetched = true;
- }
-
- await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false);
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "created";
- pushed = true;
- createdFrom = baseBranch;
- wasMutated = true;
+ RootId = rootId,
+ MergeGroupPath = path.Canonical,
+ Depth = path.Depth,
+ BranchName = branch,
+ BaseBranch = baseBranch,
+ ResultAction = "error",
+ Succeeded = false,
+ WasMutated = false,
+ WasCreated = false,
+ WasPushed = false,
+ BaseFetched = false,
+ Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. " +
+ (path.IsTopLevel
+ ? "Run 'polyphony branch ensure-feature' first to create the feature branch."
+ : "Run 'polyphony branch ensure-mg' for the parent path first."),
+ };
+ EmitMgError(
+ rootId,
+ mgPath,
+ payload.Error,
+ branch: branch,
+ baseBranch: baseBranch,
+ depth: path.Depth);
+ return ExitCodes.RoutingFailure;
}
var result = new BranchEnsureMergeGroupResult
{
Branch = branch,
BaseBranch = baseBranch,
- Action = action,
- RemoteExisted = remoteExisted,
- Pushed = pushed,
- BaseRemoteExisted = baseRemoteExisted,
- BaseFetched = baseFetched,
- CreatedFrom = createdFrom,
+ Action = outcome.Action,
+ RemoteExisted = outcome.RemoteExisted,
+ Pushed = outcome.Pushed,
+ BaseRemoteExisted = outcome.BaseRemoteExisted,
+ BaseFetched = outcome.BaseFetched,
+ CreatedFrom = outcome.CreatedFrom,
RootId = rootId,
MgPath = path.Canonical,
Depth = path.Depth,
@@ -198,13 +133,13 @@ public async Task EnsureMergeGroup(
Depth = path.Depth,
BranchName = branch,
BaseBranch = baseBranch,
- ResultAction = action,
+ ResultAction = outcome.Action,
Succeeded = true,
- WasMutated = wasMutated,
- WasCreated = string.Equals(action, "created", StringComparison.Ordinal),
- WasPushed = pushed,
- BaseFetched = baseFetched,
- Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
+ WasMutated = outcome.WasMutated,
+ WasCreated = string.Equals(outcome.Action, "created", StringComparison.Ordinal),
+ WasPushed = outcome.Pushed,
+ BaseFetched = outcome.BaseFetched,
+ Sha = await _branchEnsurer.TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
};
EmitMergeGroup(result);
return ExitCodes.Success;
@@ -249,12 +184,6 @@ public async Task EnsureMergeGroup(
ct: ct).ConfigureAwait(false);
}
- private async Task BaseExistsOnRemoteAsync(string baseBranch, string remote, CancellationToken ct)
- {
- var refs = await git.LsRemoteHeadsAsync(remote, baseBranch, ct).ConfigureAwait(false);
- return refs.Count > 0;
- }
-
private static void EmitMergeGroup(BranchEnsureMergeGroupResult result)
=> Console.WriteLine(JsonSerializer.Serialize(
result, PolyphonyJsonContext.Default.BranchEnsureMergeGroupResult));
diff --git a/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs b/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs
index a2ed4b6..91a404f 100644
--- a/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs
+++ b/src/Polyphony/Commands/BranchCommands.EnsurePlan.cs
@@ -122,110 +122,52 @@ public async Task EnsurePlan(
{
try
{
- // ── 2. Check current state of plan branch on remote and locally. ─
- var remoteRefs = await git.LsRemoteHeadsAsync(remote, branch, innerCt).ConfigureAwait(false);
- var remoteExisted = remoteRefs.Count > 0;
+ var outcome = await _branchEnsurer.EnsureAsync(
+ new BranchSpec(branch, baseBranch, remote),
+ innerCt).ConfigureAwait(false);
- var localSha = await git.RevParseLocalBranchAsync(branch, innerCt).ConfigureAwait(false);
- var localExisted = localSha is not null;
- var currentBranch = localExisted
- ? await TryGetCurrentBranchAsync(innerCt).ConfigureAwait(false)
- : null;
-
- string action;
- bool pushed = false;
- string? createdFrom = null;
- bool baseRemoteExisted;
- bool baseFetched = false;
- bool wasMutated;
-
- if (localExisted)
- {
- await git.CheckoutAsync(branch, innerCt).ConfigureAwait(false);
- action = "checked_out";
- wasMutated = currentBranch is null || !string.Equals(currentBranch, branch, StringComparison.Ordinal);
-
- if (!remoteExisted)
- {
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- pushed = true;
- wasMutated = true;
- }
-
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- }
- else if (remoteExisted)
- {
- await git.FetchAsync(remote, branch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "checked_out";
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- wasMutated = true;
- }
- else
+ if (outcome.Status == BranchEnsureStatus.BaseMissingOnRemote)
{
- // ── 3. Need to materialize from base. Confirm base exists on remote. ─
- baseRemoteExisted = await BaseExistsOnRemoteAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- if (!baseRemoteExisted)
- {
- var hint = isRootPlan
- ? "Run 'polyphony branch ensure-feature' first to create the feature branch."
- : (parent is null
- ? "Run 'polyphony branch ensure-plan' for the root plan first (--item-id == --root-id)."
- : $"Run 'polyphony branch ensure-plan --root-id {rootId} --item-id {parentItemId}' first to create the parent plan branch.");
-
- payload = new BranchEnsurePlanPayload
- {
- RootId = rootId,
- WorkItemId = itemId,
- ParentItemId = parent,
- IsRootPlan = isRootPlan,
- BranchName = branch,
- BaseBranch = baseBranch,
- ResultAction = "error",
- Succeeded = false,
- WasMutated = false,
- WasCreated = false,
- WasPushed = false,
- BaseFetched = false,
- Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. {hint}",
- };
- EmitPlanError(rootId, itemId, parentItemId,
- payload.Error,
- branch: branch,
- baseBranch: baseBranch,
- isRootPlan: isRootPlan);
- return ExitCodes.RoutingFailure;
- }
+ var hint = isRootPlan
+ ? "Run 'polyphony branch ensure-feature' first to create the feature branch."
+ : (parent is null
+ ? "Run 'polyphony branch ensure-plan' for the root plan first (--item-id == --root-id)."
+ : $"Run 'polyphony branch ensure-plan --root-id {rootId} --item-id {parentItemId}' first to create the parent plan branch.");
- // If the base isn't local, fetch and check it out so the
- // create-from-base step has a known local start point.
- var baseLocalSha = await git.RevParseLocalBranchAsync(baseBranch, innerCt).ConfigureAwait(false);
- if (baseLocalSha is null)
+ payload = new BranchEnsurePlanPayload
{
- await git.FetchAsync(remote, baseBranch, innerCt).ConfigureAwait(false);
- await git.CheckoutTrackingAsync(baseBranch, remote, innerCt).ConfigureAwait(false);
- baseFetched = true;
- }
-
- await git.CreateBranchAsync(branch, baseBranch, innerCt).ConfigureAwait(false);
- await git.PushAsync(branch, remote, innerCt).ConfigureAwait(false);
- action = "created";
- pushed = true;
- createdFrom = baseBranch;
- wasMutated = true;
+ RootId = rootId,
+ WorkItemId = itemId,
+ ParentItemId = parent,
+ IsRootPlan = isRootPlan,
+ BranchName = branch,
+ BaseBranch = baseBranch,
+ ResultAction = "error",
+ Succeeded = false,
+ WasMutated = false,
+ WasCreated = false,
+ WasPushed = false,
+ BaseFetched = false,
+ Error = $"base branch '{baseBranch}' does not exist on remote '{remote}'. {hint}",
+ };
+ EmitPlanError(rootId, itemId, parentItemId,
+ payload.Error,
+ branch: branch,
+ baseBranch: baseBranch,
+ isRootPlan: isRootPlan);
+ return ExitCodes.RoutingFailure;
}
var result = new BranchEnsurePlanResult
{
Branch = branch,
BaseBranch = baseBranch,
- Action = action,
- RemoteExisted = remoteExisted,
- Pushed = pushed,
- BaseRemoteExisted = baseRemoteExisted,
- BaseFetched = baseFetched,
- CreatedFrom = createdFrom,
+ Action = outcome.Action,
+ RemoteExisted = outcome.RemoteExisted,
+ Pushed = outcome.Pushed,
+ BaseRemoteExisted = outcome.BaseRemoteExisted,
+ BaseFetched = outcome.BaseFetched,
+ CreatedFrom = outcome.CreatedFrom,
RootId = rootId,
ItemId = itemId,
ParentItemId = parent,
@@ -239,13 +181,13 @@ public async Task EnsurePlan(
IsRootPlan = isRootPlan,
BranchName = branch,
BaseBranch = baseBranch,
- ResultAction = action,
+ ResultAction = outcome.Action,
Succeeded = true,
- WasMutated = wasMutated,
- WasCreated = string.Equals(action, "created", StringComparison.Ordinal),
- WasPushed = pushed,
- BaseFetched = baseFetched,
- Sha = await TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
+ WasMutated = outcome.WasMutated,
+ WasCreated = string.Equals(outcome.Action, "created", StringComparison.Ordinal),
+ WasPushed = outcome.Pushed,
+ BaseFetched = outcome.BaseFetched,
+ Sha = await _branchEnsurer.TryGetBranchShaAsync(branch, innerCt).ConfigureAwait(false),
};
EmitPlan(result);
return ExitCodes.Success;
diff --git a/src/Polyphony/Commands/BranchCommands.cs b/src/Polyphony/Commands/BranchCommands.cs
index beaeae4..aad8ef9 100644
--- a/src/Polyphony/Commands/BranchCommands.cs
+++ b/src/Polyphony/Commands/BranchCommands.cs
@@ -34,10 +34,12 @@ public sealed partial class BranchCommands(
Sdlc.Observers.RepoIdentityResolver repoIdentityResolver,
Sdlc.Observers.PullRequestReader pullRequestReader,
RunContext runContext,
- JournaledActionDecorator decorator)
+ JournaledActionDecorator decorator,
+ Polyphony.Branching.BranchEnsurer branchEnsurer)
{
private readonly RunContext _runContext = runContext;
private readonly JournaledActionDecorator _journalDecorator = decorator;
+ private readonly Polyphony.Branching.BranchEnsurer _branchEnsurer = branchEnsurer;
///
/// Check ADO predecessor links for blocking dependencies on a work item.
@@ -568,30 +570,6 @@ private static JournalOutcome SelectJournalOutcome(int exitCode, bool succeeded,
private static string WorkItemJournalTarget(int workItemId) => $"workitem:{workItemId}";
- private async Task TryGetCurrentBranchAsync(CancellationToken ct)
- {
- try
- {
- return await git.GetCurrentBranchAsync(ct).ConfigureAwait(false);
- }
- catch
- {
- return null;
- }
- }
-
- private async Task TryGetBranchShaAsync(string branch, CancellationToken ct)
- {
- try
- {
- return await git.RevParseLocalBranchAsync(branch, ct).ConfigureAwait(false);
- }
- catch
- {
- return null;
- }
- }
-
private static int? TryParseFeatureRootId(string branch)
{
if (!branch.StartsWith(Polyphony.Branching.BranchNameBuilder.FeaturePrefix, StringComparison.Ordinal))
diff --git a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs
index 8a8265e..c379da1 100644
--- a/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs
+++ b/src/Polyphony/Infrastructure/PolyphonyServiceRegistration.cs
@@ -49,6 +49,7 @@ public static IServiceCollection AddPolyphonyServices(
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/tests/Polyphony.Tests/Branching/BranchEnsurerTests.cs b/tests/Polyphony.Tests/Branching/BranchEnsurerTests.cs
new file mode 100644
index 0000000..e8bf5fb
--- /dev/null
+++ b/tests/Polyphony.Tests/Branching/BranchEnsurerTests.cs
@@ -0,0 +1,198 @@
+using Polyphony.Branching;
+using Polyphony.Infrastructure.Processes;
+using Polyphony.Tests.Infrastructure.Processes;
+using Shouldly;
+using Xunit;
+
+namespace Polyphony.Tests.Branching;
+
+///
+/// Focused tests for . The five
+/// BranchCommands.Ensure* verb test files already exercise the
+/// matrix end-to-end through their journal/envelope wrappers; these
+/// tests cover the shared class directly so the matrix's behavior is
+/// pinned independent of any one verb's translation layer.
+///
+public sealed class BranchEnsurerTests
+{
+ private static BranchEnsurer Create(FakeProcessRunner runner)
+ => new(new GitClient(runner));
+
+ private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists)
+ => runner.WhenExact("git", ["ls-remote", "--heads", "origin", branch],
+ new ProcessResult(0, exists ? $"abc123\trefs/heads/{branch}\n" : "", ""));
+
+ private static void StubRevParse(FakeProcessRunner runner, string branch, bool exists)
+ => runner.WhenExact("git", ["rev-parse", "--verify", $"refs/heads/{branch}"],
+ new ProcessResult(exists ? 0 : 1, exists ? "abc123\n" : "", exists ? "" : "fatal: needed a single revision"));
+
+ private static void StubCurrentBranch(FakeProcessRunner runner, string branch)
+ => runner.WhenExact("git", ["rev-parse", "--abbrev-ref", "HEAD"],
+ new ProcessResult(0, $"{branch}\n", ""));
+
+ private static void StubCheckout(FakeProcessRunner runner, string branch)
+ => runner.WhenExact("git", ["checkout", branch], new ProcessResult(0, "", ""));
+
+ private static void StubCheckoutFails(FakeProcessRunner runner, string branch, string stderr)
+ => runner.WhenExact("git", ["checkout", branch], new ProcessResult(128, "", stderr));
+
+ private static void StubCheckoutTracking(FakeProcessRunner runner, string branch)
+ => runner.WhenExact("git", ["checkout", "--track", $"origin/{branch}"], new ProcessResult(0, "", ""));
+
+ private static void StubCreateBranch(FakeProcessRunner runner, string branch, string startPoint)
+ => runner.WhenExact("git", ["checkout", "-b", branch, startPoint], new ProcessResult(0, "", ""));
+
+ private static void StubPush(FakeProcessRunner runner, string branch)
+ => runner.WhenExact("git", ["push", "-u", "origin", branch], new ProcessResult(0, "", ""));
+
+ private static void StubFetch(FakeProcessRunner runner, string refspec)
+ => runner.WhenExact("git", ["fetch", "origin", refspec], new ProcessResult(0, "", ""));
+
+ // ─── Local + remote both exist — pure checkout, no base probe ────────
+
+ [Fact]
+ public async Task EnsureAsync_LocalAndRemoteBothExist_ChecksOut()
+ {
+ var runner = new FakeProcessRunner();
+ StubLsRemote(runner, "feature/1", exists: true);
+ StubRevParse(runner, "feature/1", exists: true);
+ StubCurrentBranch(runner, "main");
+ StubCheckout(runner, "feature/1");
+
+ var outcome = await Create(runner).EnsureAsync(
+ new BranchSpec("feature/1", "main", "origin",
+ TolerateWorktreeConflict: true,
+ IncludeBaseOnRemoteCheck: false),
+ CancellationToken.None);
+
+ outcome.Status.ShouldBe(BranchEnsureStatus.Success);
+ outcome.Action.ShouldBe("checked_out");
+ outcome.RemoteExisted.ShouldBeTrue();
+ outcome.Pushed.ShouldBeFalse();
+ outcome.WasMutated.ShouldBeTrue(); // currentBranch (main) != target (feature/1)
+ outcome.WorktreePath.ShouldBeNull();
+ }
+
+ // ─── Remote only — fetch + tracking checkout ─────────────────────────
+
+ [Fact]
+ public async Task EnsureAsync_RemoteOnly_FetchesAndTracks()
+ {
+ var runner = new FakeProcessRunner();
+ StubLsRemote(runner, "feature/2", exists: true);
+ StubRevParse(runner, "feature/2", exists: false);
+ StubFetch(runner, "feature/2");
+ StubCheckoutTracking(runner, "feature/2");
+
+ var outcome = await Create(runner).EnsureAsync(
+ new BranchSpec("feature/2", "main", "origin",
+ IncludeBaseOnRemoteCheck: false),
+ CancellationToken.None);
+
+ outcome.Status.ShouldBe(BranchEnsureStatus.Success);
+ outcome.Action.ShouldBe("checked_out");
+ outcome.RemoteExisted.ShouldBeTrue();
+ outcome.WasMutated.ShouldBeTrue();
+ outcome.BaseRemoteExisted.ShouldBeFalse();
+ outcome.BaseFetched.ShouldBeFalse();
+ }
+
+ // ─── Neither exists, base missing on remote → BaseMissingOnRemote ────
+
+ [Fact]
+ public async Task EnsureAsync_NeitherExistsAndBaseMissingOnRemote_ReturnsBaseMissing()
+ {
+ var runner = new FakeProcessRunner();
+ StubLsRemote(runner, "plan/9-7", exists: false);
+ StubRevParse(runner, "plan/9-7", exists: false);
+ StubLsRemote(runner, "plan/9", exists: false);
+
+ var outcome = await Create(runner).EnsureAsync(
+ new BranchSpec("plan/9-7", "plan/9", "origin"),
+ CancellationToken.None);
+
+ outcome.Status.ShouldBe(BranchEnsureStatus.BaseMissingOnRemote);
+ outcome.Action.ShouldBe("error");
+ outcome.MissingBase.ShouldBe("plan/9");
+ outcome.MissingBaseRemote.ShouldBe("origin");
+ }
+
+ // ─── Neither exists, base on remote not local → fetch base + create ─
+
+ [Fact]
+ public async Task EnsureAsync_NeitherExistsAndBaseNeedsFetch_FetchesBaseAndCreatesTarget()
+ {
+ var runner = new FakeProcessRunner();
+ StubLsRemote(runner, "plan/9-7", exists: false);
+ StubRevParse(runner, "plan/9-7", exists: false);
+ StubLsRemote(runner, "plan/9", exists: true);
+ StubRevParse(runner, "plan/9", exists: false);
+ StubFetch(runner, "plan/9");
+ StubCheckoutTracking(runner, "plan/9");
+ StubCreateBranch(runner, "plan/9-7", "plan/9");
+ StubPush(runner, "plan/9-7");
+
+ var outcome = await Create(runner).EnsureAsync(
+ new BranchSpec("plan/9-7", "plan/9", "origin"),
+ CancellationToken.None);
+
+ outcome.Status.ShouldBe(BranchEnsureStatus.Success);
+ outcome.Action.ShouldBe("created");
+ outcome.RemoteExisted.ShouldBeFalse();
+ outcome.Pushed.ShouldBeTrue();
+ outcome.CreatedFrom.ShouldBe("plan/9");
+ outcome.BaseRemoteExisted.ShouldBeTrue();
+ outcome.BaseFetched.ShouldBeTrue();
+ outcome.WasMutated.ShouldBeTrue();
+ }
+
+ // ─── Worktree conflict with tolerance ON + remote absent → still push ─
+
+ [Fact]
+ public async Task EnsureAsync_LocalInOtherWorktreeAndRemoteAbsent_StillPushesWithMutation()
+ {
+ // AB#211 + the post-rubber-duck pin: worktree-conflict tolerance
+ // returns exists_in_other_worktree, but if the branch hasn't been
+ // pushed yet the ensurer still pushes — and wasMutated flips true.
+ var runner = new FakeProcessRunner();
+ const string sibling = "C:/repos/sibling";
+ StubLsRemote(runner, "feature/3", exists: false);
+ StubRevParse(runner, "feature/3", exists: true);
+ StubCurrentBranch(runner, "main");
+ StubCheckoutFails(runner, "feature/3", $"fatal: 'feature/3' is already used by worktree at '{sibling}'\n");
+ StubPush(runner, "feature/3");
+
+ var outcome = await Create(runner).EnsureAsync(
+ new BranchSpec("feature/3", "main", "origin",
+ TolerateWorktreeConflict: true,
+ IncludeBaseOnRemoteCheck: false),
+ CancellationToken.None);
+
+ outcome.Status.ShouldBe(BranchEnsureStatus.Success);
+ outcome.Action.ShouldBe("exists_in_other_worktree");
+ outcome.WorktreePath.ShouldBe(sibling);
+ outcome.Pushed.ShouldBeTrue();
+ outcome.WasMutated.ShouldBeTrue();
+ }
+
+ // ─── Worktree conflict with tolerance OFF → exception propagates ─────
+
+ [Fact]
+ public async Task EnsureAsync_LocalInOtherWorktreeAndToleranceOff_ThrowsExternalToolException()
+ {
+ // Plan/Impl/MG/Evidence verbs don't opt into worktree tolerance —
+ // the exception must propagate so their catch block can surface a
+ // CacheError envelope.
+ var runner = new FakeProcessRunner();
+ StubLsRemote(runner, "plan/9", exists: true);
+ StubRevParse(runner, "plan/9", exists: true);
+ StubCurrentBranch(runner, "main");
+ StubCheckoutFails(runner, "plan/9", "fatal: 'plan/9' is already used by worktree at '/tmp/sibling'\n");
+
+ var ensurer = Create(runner);
+ await Should.ThrowAsync(() =>
+ ensurer.EnsureAsync(
+ new BranchSpec("plan/9", "feature/9", "origin"),
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs
index c8c624e..d7cce0d 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsAssertOnImplTests.cs
@@ -33,7 +33,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand(
var validator = new TransitionValidator(config);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubCurrentBranch(FakeProcessRunner runner, string branch)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs
index 18a09b1..7ae9212 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsCheckDepsTests.cs
@@ -31,7 +31,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand(
var validator = new TransitionValidator(config);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubSync(FakeProcessRunner runner)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs
index dfac1c5..8854034 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsCloseScopeTests.cs
@@ -21,7 +21,7 @@ public sealed class BranchCommandsCloseScopeTests : CommandTestBase
var validator = new TransitionValidator(cfg);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, Repository, validator, git, cfg, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, Repository, validator, git, cfg, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubSync(FakeProcessRunner runner)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs
index 0df827e..96aaab8 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureFeatureTests.cs
@@ -35,7 +35,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand(
var validator = new TransitionValidator(config);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs
index 6e68c79..3911013 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureImplTests.cs
@@ -31,7 +31,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand(
var validator = new TransitionValidator(config);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs
index c9384d5..de425a0 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsureMergeGroupTests.cs
@@ -31,7 +31,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand(
var validator = new TransitionValidator(config);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
// Stubs that mirror IGitClient's actual call shapes so tests
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs
index e8bf02e..db94415 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsEnsurePlanTests.cs
@@ -35,7 +35,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand(
var validator = new TransitionValidator(config);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs
index b9e2e85..75aaaaa 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsJournalTests.cs
@@ -269,7 +269,8 @@ private static void AssertEffect(
new Polyphony.Sdlc.Observers.RepoIdentityResolver(git),
new Polyphony.Sdlc.Observers.PullRequestReader(gh, null),
new RunContext(runId),
- new JournaledActionDecorator(store));
+ new JournaledActionDecorator(store),
+ new Polyphony.Branching.BranchEnsurer(git));
return (command, runner, store);
}
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs
index 7e7e2a9..88502c2 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsLoadTreeTests.cs
@@ -22,7 +22,7 @@ public sealed class BranchCommandsLoadTreeTests : CommandTestBase
var ghClient = new GhClient(runner);
var walker = new HierarchyWalker(Config, Repository);
var validator = new TransitionValidator(Config);
- return (new BranchCommands(twigClient, walker, Repository, validator, gitClient, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(gitClient), new Polyphony.Sdlc.Observers.PullRequestReader(ghClient, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twigClient, walker, Repository, validator, gitClient, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(gitClient), new Polyphony.Sdlc.Observers.PullRequestReader(ghClient, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(gitClient)), runner);
}
private static void StubSync(FakeProcessRunner runner)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs
index d3375fc..ec4a768 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsMarkImplMergedTests.cs
@@ -32,7 +32,8 @@ public sealed class BranchCommandsMarkImplMergedTests : CommandTestBase
new Polyphony.Sdlc.Observers.RepoIdentityResolver(git),
new Polyphony.Sdlc.Observers.PullRequestReader(gh, null),
JournalTestSupport.CreateRunContext(),
- JournalTestSupport.CreateDecorator()), runner);
+ JournalTestSupport.CreateDecorator(),
+ new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubSync(FakeProcessRunner runner)
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs
index 07c470f..3a82fce 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsNextImplTests.cs
@@ -23,7 +23,7 @@ public sealed class BranchCommandsNextImplTests : CommandTestBase
var c = cfg ?? Config;
var walker = new HierarchyWalker(c, Repository);
var validator = new TransitionValidator(c);
- return (new BranchCommands(twig, walker, Repository, validator, git, c, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, Repository, validator, git, c, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubSync(FakeProcessRunner runner)
@@ -337,7 +337,7 @@ public async Task NextImpl_PostSyncStateMismatch_EmitsErrorWithDiagnostics()
var gh = new GhClient(runner);
var walker = new HierarchyWalker(Config, Repository);
var validator = new TransitionValidator(Config);
- var cmd = new BranchCommands(twig, walker, Repository, validator, git, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator());
+ var cmd = new BranchCommands(twig, walker, Repository, validator, git, Config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git));
StubSync(runner);
StubConfig(runner);
diff --git a/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs b/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs
index 2a60108..353d048 100644
--- a/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchCommandsRouteTests.cs
@@ -24,7 +24,7 @@ public sealed class BranchCommandsRouteTests : CommandTestBase
var validator = new TransitionValidator(c);
var resolver = new Polyphony.Sdlc.Observers.RepoIdentityResolver(git);
var reader = new Polyphony.Sdlc.Observers.PullRequestReader(gh, null);
- return (new BranchCommands(twig, walker, Repository, validator, git, c, resolver, reader, JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, Repository, validator, git, c, resolver, reader, JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubSync(FakeProcessRunner runner)
diff --git a/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs b/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs
index ab2464f..0bae2f6 100644
--- a/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs
+++ b/tests/Polyphony.Tests/Commands/BranchEnsureEvidenceTests.cs
@@ -34,7 +34,7 @@ private static (BranchCommands Command, FakeProcessRunner Runner) CreateCommand(
var validator = new TransitionValidator(config);
var git = new GitClient(runner);
var gh = new GhClient(runner);
- return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator()), runner);
+ return (new BranchCommands(twig, walker, repo, validator, git, config, new Polyphony.Sdlc.Observers.RepoIdentityResolver(git), new Polyphony.Sdlc.Observers.PullRequestReader(gh, null), JournalTestSupport.CreateRunContext(), JournalTestSupport.CreateDecorator(), new Polyphony.Branching.BranchEnsurer(git)), runner);
}
private static void StubLsRemote(FakeProcessRunner runner, string branch, bool exists)
diff --git a/tests/Polyphony.Tests/Journal/JournalE2ETests.cs b/tests/Polyphony.Tests/Journal/JournalE2ETests.cs
index 41daa73..9c6f3a7 100644
--- a/tests/Polyphony.Tests/Journal/JournalE2ETests.cs
+++ b/tests/Polyphony.Tests/Journal/JournalE2ETests.cs
@@ -38,7 +38,8 @@ public async Task EnsureEvidenceBranch_ThenShow_RoundTripsRealJournalEntry()
new Polyphony.Sdlc.Observers.RepoIdentityResolver(git),
new Polyphony.Sdlc.Observers.PullRequestReader(gh, null),
new RunContext("run-e2e"),
- new JournaledActionDecorator(store));
+ new JournaledActionDecorator(store),
+ new Polyphony.Branching.BranchEnsurer(git));
var journalCommands = new JournalCommands(store);
runner.WhenExact("git", ["ls-remote", "--heads", "origin", "evidence/100-200"], new ProcessResult(0, "", ""));