Skip to content

feat(sync): restore missing files and archive only with detach --archive - #857

Open
ctawiah wants to merge 5 commits into
ctawiah/sync-lock-baselinefrom
ctawiah/sync-restore-missing-files
Open

ctawiah wants to merge 5 commits into
ctawiah/sync-lock-baselinefrom
ctawiah/sync-restore-missing-files

Conversation

@ctawiah

@ctawiah ctawiah commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Context

A missing local file made sync archive the variation in LaunchDarkly. A file is often missing only because the working copy does not have it yet, for example after a Git pull or a branch switch. A plain sync, or a watch session that applies changes, could then archive a variation that other engineers use.

This PR is stacked on #856. That PR makes sync.lock track every project, so sync can find a variation whose file is missing.

What changes

  • Sync restores a missing local file from LaunchDarkly. The sync plan never archives a variation, so the archive_server action is removed.
  • ldcli sync prompts detach <selector> --archive is the only way to archive a variation. It archives each selected variation in LaunchDarkly, and then stops syncing it.
  • detach --archive asks for confirmation in a terminal. With --no-input, it needs --yes, the same rule as other destructive changes.
  • The archive runs first, and an already archived variation does not fail. If a later step fails, the same command can run again.
  • The confirmation prompt moves to the interactive package, so sync and detach use one copy.
  • The command is now ldcli sync prompts, plural, like the other resource commands. There is no alias for sync prompt.

Stop syncing a variation and archive it:

ldcli sync prompts detach production/support/default --archive --yes --no-input

Review focus

  • Is it right that a missing file always comes back, and that only detach --archive archives?
  • Is the order in detach --archive safe when a step fails: archive, then baseline, then file deletion?
  • Is the archive confirmation clear in the terminal and with --no-input?

Verification

  • go build ./...
  • go vet ./...
  • go test -race ./internal/sync/... ./cmd/sync/...
  • TestPromptRestoresTrackedMissingFileInsteadOfArchiving deletes a tracked file, runs sync, and checks that the file comes back and nothing is archived.

Related changes

  1. feat(sync): keep each working copy's baseline in sync.lock #856: keep each working copy's baseline in sync.lock
  2. This PR: restore missing files and archive only with detach --archive

Devin Review


Note

Overview
Renames the CLI entry point to ldcli sync prompts (no prompt alias) and changes how missing local variations are handled: plain sync restores them from LaunchDarkly instead of archiving, and archive_server is removed from the sync plan.

detach --archive (with --yes / terminal confirmation via shared interactive.Confirm) is now the only path to archive variations in LaunchDarkly; detach archives first, then updates baseline and deletes local files, with idempotent retries when a variation is already archived.

sync.lock no longer stores manifest versions; it can store ref for linked prompt files so a restored variation file keeps its link when safe. A working copy without sync.lock no longer treats the shared remote manifest as its baseline, so it won’t restore/archive variations another branch synced. Conflict UI uses inline select so plan text stays visible.

Reviewed by Cursor Bugbot for commit fb0cf44. Bugbot is set up for automated code reviews on this repo. Configure here.

A missing local file made sync archive the variation in LaunchDarkly.
A file is often missing only because the working copy does not have it
yet, for example after a Git pull or a branch switch. A plain sync could
then archive a variation that other engineers use.

Sync now restores a missing file from LaunchDarkly. The sync plan never
archives a variation. "ldcli sync prompt detach --archive" is the only
way to archive one. It archives each selected variation, and then stops
syncing it. It asks for confirmation, and with --no-input it needs
--yes. The archive runs first, and an archived variation does not fail,
so the same command can run again after a later step fails.

The confirmation prompt moves to the interactive package, so that sync
and detach use the same prompt.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment thread internal/sync/prompt/plan.go
Comment thread internal/sync/detach/detach.go Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Review notes. I made no code changes.

1. Medium: you cannot safely rerun detach --archive, because archiving an archived variation returns 400, not 404

archiveVariations (internal/sync/detach/detach.go) skips only syncapi.IsNotFound errors, and IsNotFound matches only HTTP 404. The comment says "a variation that is already archived does not fail". But in gonfalon, Variation.Archive returns domain.NewError("cannot archive an archived variation"), and error_response.go maps domain.Error to ferror.NewBadRequest (400). The unit test case "variation already archived" in detach_test.go fakes a 404, so it does not match the real API.

Effect: archive runs before detachResources. If the baseline save or the file delete fails after the archive succeeds, the variation is still tracked. Then every rerun of the same command fails with archive variation ...: cannot archive an archived variation. With more than one selection, the loop stops at the first error. The earlier variations are then archived but still tracked, and a rerun fails on them. The user has to know to run plain detach (or sync) to recover.

2. Low: --archive deletes local edits that were never pushed, without warning

The confirm prompt only asks Archive these variations in LaunchDarkly? [y/N]. Then detachResources deletes the local files even if they have changes that were never pushed. After that, the content survives only in Git history, or in the archived variation if the edits had been pushed. You could say this in the prompt, or check for unpushed local changes first.

3. Inherited from #856: first sync with no sync.lock restores other branches' prompts

Because the baseline falls back to the shared remote manifest when there is no lock, the new "missing file means restore" rule pulls variations from other branches into this branch (checked with a scratch test: second action=update_local). This is no longer data loss as it was in #856, but it is likely not what users expect. See the #856 comment.

Written by Devin

Comment thread internal/sync/detach/detach.go
Comment thread internal/sync/prompt/plan.go
Comment thread cmd/sync/prompt.go
Comment thread internal/sync/detach/detach_test.go

@tonytrinh3 tonytrinh3 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ctawiah Overall it looks good to me. Devin pointed out additional tests that needed to be added.

A restored variation file lost its link to the prompt file that it used.
The lock now records the link of each variation, and a restore keeps it.
Sync stops with a clear error, and writes nothing, when it cannot restore
the link safely. This happens when the working copy has no sync.lock,
when the linked file is not available, or when the linked file has local
edits.

Detach now reads each variation before it archives it, because the API
rejects the archive of an archived variation. A rerun after a failed step
skips the variations that are already archived and finishes. Detach also
uses the command context, so a canceled command does not archive.

The lock no longer stores the remote version of each entry. Each save
still sends the version that sync read, so optimistic locking does not
change. A lock entry now changes only when its content or its link
changes, which keeps Git diffs and merge conflicts small.

New tests cover linked restores, unsafe restores, a partial failure with
several variations, a failed delete followed by a rerun, the interactive
archive confirmation, and detach --archive through the CLI.
@ctawiah
ctawiah requested a review from tonytrinh3 October 8, 2026 23:32
Every other ldcli resource command uses a plural noun, such as flags,
projects, and segments. The command also syncs every prompt variation
in the workspace, not one. "ldcli sync prompts" now replaces
"ldcli sync prompt", with no alias.
The remote manifest is shared by every branch. A working copy without
sync.lock used it as its baseline, so variations from other branches
looked tracked and missing. Sync then restored or archived them.

Now a missing sync.lock gives an empty baseline. The first sync adopts
the local files and writes the lock. This removes the HasLockFile flag,
the lock write on a sync without changes, and the restore error for a
working copy without sync.lock.
@ctawiah
ctawiah marked this pull request as ready for review October 9, 2026 20:04
@ctawiah
ctawiah requested a review from a team as a code owner October 9, 2026 20:04

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4b72020. Configure here.

// Version is the version of the remote manifest entry, which LaunchDarkly
// uses for optimistic locking. Only the remote manifest has it. The lock
// does not store it, so that a lock entry changes only with its content.
Version int `yaml:"-"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing lock files fail to decode

High Severity

sync.lock still uses formatVersion: 1, but Version is now tagged yaml:"-" while decodeLock enables KnownFields. Existing lock files that include version fail to decode, so sync, detach, and watch cannot load the workspace baseline.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4b72020. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I confirmed this at 4b72020 with a scratch test, which I deleted afterward. I passed decodeLock a formatVersion: 1 lock that has version: 6 on an entry, and it returns decode: yaml: unmarshal errors (field version not found). The cause is the combination of KnownFields(true) and Version being tagged yaml:"-".

How much this matters depends on release order:

Ways to fix it:

  • Drop version from the lock format in feat(sync): keep each working copy's baseline in sync.lock #856 itself, so no lock with version ever exists.
  • Or keep accepting version on read and ignore it, for example with a lock-only struct that has Version int \yaml:"version,omitempty"`` and is never written.
  • Or bump formatVersion to 2 and migrate version 1 files.

Whichever you pick, a test that decodes a version 1 lock containing version would catch a regression.

Written by Devin

The conflict choice opened in the terminal's alternate screen. That
screen hid the diff that sync writes just before the question, so the
user saw only the three choices.

The conflict choice now shows below the current output, and the diff
stays above it. The project, config, and model config pickers keep the
full screen, because their lists can be long.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants