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, "", ""));