Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.9.3
github.com/fsnotify/fsnotify v1.9.0
github.com/getkin/kin-openapi v0.144.0
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
Expand Down Expand Up @@ -62,7 +63,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
Expand Down
30 changes: 26 additions & 4 deletions internal/sync/interactive/form.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package interactive

import (
"context"
"errors"
"fmt"
"io"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
Expand All @@ -25,6 +25,17 @@ func Select[T any](
output io.Writer,
title string,
choices []Choice[T],
) (T, bool, error) {
return SelectContext(context.Background(), input, output, title, choices)
}

// SelectContext asks the user to choose one value and stops when the context ends.
func SelectContext[T any](
ctx context.Context,
input io.Reader,
output io.Writer,
title string,
choices []Choice[T],
) (T, bool, error) {
var zero T
if len(choices) == 0 {
Expand All @@ -37,7 +48,8 @@ func Select[T any](
options[index] = huh.NewOption(choiceLabel(choice), index)
}

canceled, err := RunForm(
canceled, err := RunFormContext(
ctx,
input,
output,
huh.NewSelect[int]().
Expand Down Expand Up @@ -97,13 +109,23 @@ func RunForm(
input io.Reader,
output io.Writer,
fields ...huh.Field,
) (bool, error) {
return RunFormContext(context.Background(), input, output, fields...)
}

// RunFormContext runs fields until they complete, abort, or the context ends.
func RunFormContext(
ctx context.Context,
input io.Reader,
output io.Writer,
fields ...huh.Field,
) (bool, error) {
err := huh.NewForm(huh.NewGroup(fields...)).
WithInput(input).
WithOutput(output).
WithAccessible(false).
WithTheme(formTheme()).
WithProgramOptions(tea.WithAltScreen()).
Run()
RunWithContext(ctx)
if errors.Is(err, huh.ErrUserAborted) {
return true, nil
}
Expand Down
229 changes: 229 additions & 0 deletions internal/sync/prompt/conflict.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package prompt

import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"time"

syncconsole "github.com/launchdarkly/ldcli/internal/sync/console"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
)

type conflictResolution string

const (
useLaunchDarkly conflictResolution = "launchdarkly"
useLocal conflictResolution = "local"
abortConflict conflictResolution = "abort"
)

type conflictResolutionResult struct {
resolutions map[ResourceID]conflictResolution
aborted bool
sourcesChanged bool
}

type conflictChoice struct {
resolution conflictResolution
aborted bool
sourcesChanged bool
}

// resolveConflicts shows each conflict and asks which side should win.
func resolveConflicts(
options Options,
plan Plan,
reader io.Reader,
interactive bool,
watched *watchedSources,
) (conflictResolutionResult, error) {
var conflicts []PlannedResource
for _, resource := range plan.Resources {
if resource.Action == ActionConflict {
conflicts = append(conflicts, resource)
}
}
if len(conflicts) == 0 {
return conflictResolutionResult{}, nil
}
if !interactive {
return conflictResolutionResult{}, fmt.Errorf(
"interactive conflict resolution requires a terminal; rerun in a terminal or resolve the conflict manually",
)
}

result := conflictResolutionResult{resolutions: make(map[ResourceID]conflictResolution, len(conflicts))}
for _, resource := range conflicts {
conflict := Plan{Resources: []PlannedResource{resource}}
if err := writePlanReview(options.ErrorOutput, "plaintext", conflict, terminalWidth(options.ErrorOutput)); err != nil {
return conflictResolutionResult{}, err
}

choice, err := readConflictChoice(options, reader, watched)
if err != nil {
return conflictResolutionResult{}, err
}
if choice.sourcesChanged {
result.sourcesChanged = true
return result, nil
}
if choice.aborted {
result.aborted = true
return result, nil
}
result.resolutions[resource.ID] = choice.resolution
}
return result, nil
}

// readConflictChoice waits for a regular terminal choice or a watch-aware
// choice that can be interrupted by another source change.
func readConflictChoice(options Options, reader io.Reader, watched *watchedSources) (conflictChoice, error) {
if watched != nil {
return promptWatchedConflictResolution(options.Context, options.Input, options.ErrorOutput, *watched)
}
return promptConflictResolution(context.Background(), reader, options.ErrorOutput)
}

// promptConflictResolution asks which side should win one conflict.
func promptConflictResolution(
ctx context.Context,
input io.Reader,
output io.Writer,
) (conflictChoice, error) {
resolution, canceled, err := syncinteractive.SelectContext(
ctx,
input,
output,
"Choose how to resolve this conflict",
[]syncinteractive.Choice[conflictResolution]{
{Title: "Use LaunchDarkly", Value: useLaunchDarkly},
{Title: "Use local", Value: useLocal},
{Title: "Abort and resolve manually", Value: abortConflict},
},
)
if err != nil {
return conflictChoice{}, err
}
choice := conflictChoice{
resolution: resolution,
aborted: canceled || resolution == abortConflict,
}
writeConflictChoice(output, choice)
return choice, nil
}

// promptWatchedConflictResolution refreshes the plan if a source changes
// while the conflict selector is open.
func promptWatchedConflictResolution(
ctx context.Context,
input io.Reader,
output io.Writer,
watched watchedSources,
) (conflictChoice, error) {
promptContext, cancel := context.WithCancel(ctx)
defer cancel()

changeResult := make(chan error, 1)
// Race the form against filesystem changes. Canceling the shared child
// context guarantees exactly one path wins and the other exits promptly.
go func() {
changeResult <- watched.WaitForChange(promptContext)
cancel()
}()

choice, promptErr := promptConflictResolution(promptContext, input, output)
cancel()
changeErr := <-changeResult
if changeErr == nil {
_ = syncconsole.New(output).Line("\nA watched file changed; refreshing...")
return conflictChoice{sourcesChanged: true}, nil
}
if !errors.Is(changeErr, context.Canceled) {
return conflictChoice{}, changeErr
}
if promptErr == nil && choice.aborted {
return conflictChoice{}, context.Canceled
}
Comment thread
cursor[bot] marked this conversation as resolved.
if promptErr != nil {
if ctx.Err() != nil {
return conflictChoice{}, context.Canceled
}
return conflictChoice{}, promptErr
}
return choice, nil
}

// writeConflictChoice confirms the selected resolution after the form exits.
func writeConflictChoice(output io.Writer, choice conflictChoice) {
console := syncconsole.New(output)
switch {
case choice.aborted:
_ = console.Line("Sync canceled; conflict left unresolved.")
case choice.resolution == useLaunchDarkly:
_ = console.Line("Using LaunchDarkly.")
case choice.resolution == useLocal:
_ = console.Line("Using local.")
}
}

type watchedSources struct {
watcher *sourceWatcher
snapshot [sha256.Size]byte
debounce time.Duration
}

// WaitForChange waits until the watched source content differs from the state
// used to build the current plan.
func (watched watchedSources) WaitForChange(ctx context.Context) error {
for {
if err := watched.watcher.WaitForChange(ctx, watched.debounce); err != nil {
return err
}
current, err := sourceSnapshot(watched.watcher.root)
if err != nil {
return err
}
if current != watched.snapshot {
return nil
}
}
}

// applyConflictResolutions replaces conflict actions with the selected direction.
func applyConflictResolutions(plan Plan, resolutions map[ResourceID]conflictResolution) Plan {
resolved := Plan{Resources: append([]PlannedResource(nil), plan.Resources...)}
for index := range resolved.Resources {
resource := &resolved.Resources[index]
resolution, ok := resolutions[resource.ID]
if !ok || resource.Action != ActionConflict {
continue
}
resource.Action = resolvedConflictAction(*resource, resolution)
}
return resolved
}

// resolvedConflictAction returns the operation needed for the chosen side to win.
func resolvedConflictAction(resource PlannedResource, resolution conflictResolution) Action {
switch resolution {
case useLaunchDarkly:
if resource.Server == nil {
return ActionDeleteLocal
}
return ActionUpdateLocal
case useLocal:
if resource.Local == nil {
return ActionArchiveServer
}
if resource.Server == nil {
return ActionCreateServer
}
return ActionUpdateServer
default:
return ActionConflict
}
}
Loading
Loading