Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4684959
[RAPTOR-19525] test(workload): rework fakeFilesClient into self-consi…
ajalon1 Aug 26, 2026
af4718a
[RAPTOR-19525] fix(workload): hash uploads while streaming so the man…
ajalon1 Aug 26, 2026
9289635
[RAPTOR-19525] test(workload): cover zip uploader hash-while-streaming
ajalon1 Aug 26, 2026
3f5d58e
[RAPTOR-19525] test(workload): prove failed/partial uploads never adv…
ajalon1 Aug 26, 2026
1881020
[RAPTOR-19525] fix(workload): write manifest before config in Phase 6…
ajalon1 Aug 26, 2026
6c56aa1
[RAPTOR-19525] test(workload): lock in content-hash-only change detec…
ajalon1 Aug 26, 2026
d34bd2f
[RAPTOR-19525] test(workload): guard sync surroundings against regres…
ajalon1 Aug 26, 2026
440767c
[RAPTOR-19525] fix(filesapi): send zip-path overwrite as a multipart …
ajalon1 Aug 27, 2026
44c38a3
[RAPTOR-19525] fix(workload): repair the mangled license headers on t…
ajalon1 Aug 29, 2026
8288d1d
[RAPTOR-19525] fix(workload): adapt the wizard-redirect wait fake to …
ajalon1 Aug 29, 2026
f147731
[RAPTOR-19525] fix(workload): discard the sync rollback at Phase 6 en…
ajalon1 Aug 29, 2026
b269eab
[RAPTOR-19525] docs(test): correct the DownloadFile fake comment to n…
ajalon1 Aug 29, 2026
c3933fa
[RAPTOR-19525] fix(workload): abort Phase 6 when the rollback Discard…
ajalon1 Aug 29, 2026
8029188
test(workload): nil-guard the rollback-dir chmod cleanup against fail…
ajalon1 Aug 29, 2026
be9ce93
[RAPTOR-19525] test(workload): model downloads in the fake client and…
ajalon1 Aug 27, 2026
41a9e36
[RAPTOR-19525] feat(workload): add --verify flag to code sync, carrie…
ajalon1 Aug 28, 2026
192d488
[RAPTOR-19525] feat(workload): detect BASE-vs-REMOTE divergence under…
ajalon1 Aug 28, 2026
a30b119
[RAPTOR-19525] feat(workload): repair a poisoned manifest from the re…
ajalon1 Aug 28, 2026
40d204f
[RAPTOR-19525] feat(workload): verify after apply that the server hol…
ajalon1 Aug 28, 2026
81856f2
[RAPTOR-19525] feat(workload): report symlink kind and filter ignored…
ajalon1 Aug 28, 2026
95eaa34
[RAPTOR-19525] feat(workload): surface skipped symlinks to the user v…
ajalon1 Aug 28, 2026
27c35e9
[RAPTOR-19525] test(workload): prove the three fixes compose in combi…
ajalon1 Aug 28, 2026
eeb129f
[RAPTOR-19525] test(workload): add Windows unit coverage for SHA-256 …
ajalon1 Aug 28, 2026
b9fe097
[RAPTOR-19525] test(workload): harden test quality where scrutiny fla…
ajalon1 Aug 28, 2026
25e2fa0
test(workload): pin sync temp cleanup and checkout/versions surroundings
ajalon1 Aug 28, 2026
8aa8e25
[RAPTOR-19525] refactor(filesapi): drop the vestigial query parameter…
ajalon1 Aug 28, 2026
7c38cbe
[RAPTOR-19525] docs(workload): state the shallow-copy invariant at th…
ajalon1 Aug 29, 2026
36e1faf
[RAPTOR-19525] test(workload): sweep the actionable symlink-visibilit…
ajalon1 Aug 29, 2026
de6d8c8
[RAPTOR-19525] fix(codesync): make --verify divergence prose honest a…
ajalon1 Aug 29, 2026
c795e6c
[RAPTOR-19525] docs(artifact): document sync overwrite semantics, --v…
ajalon1 Aug 29, 2026
1c244b5
[RAPTOR-19525] test(workload): adapt the rollback-discard fake to the…
ajalon1 Aug 29, 2026
c17f9c9
[RAPTOR-19525] test(workload): repair the corrupted license header on…
ajalon1 Aug 29, 2026
eb588b7
[RAPTOR-19525] fix(workload): close the zip temp before failure-path …
ajalon1 Aug 29, 2026
3468bd6
test(workload): drop dangling fake comment and vacuous path-safety as…
ajalon1 Aug 29, 2026
6c3ea31
fix(workload): surface the zip temp-removal error and pin the unclose…
ajalon1 Aug 29, 2026
f1966df
test(workload): harden warn-log capture and notice negatives
ajalon1 Aug 31, 2026
9efeb3d
[RAPTOR-19525] fix(workload): address review findings on symlinks, lo…
ajalon1 Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions cmd/artifact/code/checkout/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,3 +525,58 @@ func TestCheckout_PromptsForVersionWhenMissing(t *testing.T) {
assert.Contains(t, buf.String(), "dr artifact code versions")
assert.DirExists(t, wapi.CheckoutDir(dir, verA))
}

// The download stages into a .tmp-<version>-* sibling of the final snapshot
// dir and swaps it into place. Regression guard for the surrounding-state
// contract: whether the checkout succeeds or fails mid-download, the staging
// directory must be gone from the checkouts parent — renamed away on success,
// removed by the failure defer otherwise.
func TestCheckout_LeavesNoTempDirsInCheckoutsParent(t *testing.T) {
// Failure first: the download dies mid-way and the staging dir must be
// cleaned up rather than stranded next to the (absent) snapshot.
failDir := initLinkedDir(t, "cat-1")

failing := &fakeClient{
versions: []filesapi.CatalogVersion{{ID: verA}},
content: map[string][]byte{"a.txt": []byte("a")},
downloadErr: errors.New("network died"),
}

cmd, _ := newTestCmd(t, failDir, fakeDeps(draftArtifact("art-abc-123"), failing), []string{verA})

require.Error(t, cmd.Execute())

assertNoTmpStagingDirs(t, failDir)

// Then success: the staging dir is renamed to the snapshot, so the
// parent must hold only the snapshot — no staging residue.
okDir := initLinkedDir(t, "cat-1")

fc := &fakeClient{
versions: []filesapi.CatalogVersion{{ID: verA}},
content: map[string][]byte{"a.txt": []byte("a")},
}

cmd2, _ := newTestCmd(t, okDir, fakeDeps(draftArtifact("art-abc-123"), fc), []string{verA})

require.NoError(t, cmd2.Execute())

assertNoTmpStagingDirs(t, okDir)
}

// assertNoTmpStagingDirs fails when any .tmp- prefixed staging directory
// survived a checkout in the project's checkouts parent.
func assertNoTmpStagingDirs(t *testing.T, dir string) {
t.Helper()

entries, err := os.ReadDir(wapi.CheckoutsDir(dir))
if os.IsNotExist(err) {
return
}

require.NoError(t, err)

for _, e := range entries {
assert.NotContains(t, e.Name(), ".tmp-", "checkout staging directory must not survive the command")
}
}
181 changes: 170 additions & 11 deletions cmd/artifact/code/codesync/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"errors"
"fmt"
"io"
"sort"
"strings"

"github.com/datarobot/cli/cmd/artifact/code/internal/dirprompt"
"github.com/datarobot/cli/cmd/artifact/code/internal/format"
Expand Down Expand Up @@ -50,6 +52,8 @@ type engineRunner interface {
StateMigrationNotice() string
IgnoreFileNotice() string
LockedNotice() string
Divergences() []sync.Divergence
SkippedSymlinks() []sync.SkippedSymlink
Fetcher() display.ContentFetcher
}

Expand All @@ -71,11 +75,15 @@ type Deps struct {

// runFlags is the parsed view of the boolean flags that gate
// finishSync's render/prompt/execute decisions. Grouped so the inner
// helpers don't carry a three-bool tail through every signature.
// helpers don't carry a bool tail through every signature. Verify is
// carried here for the engine Options and telemetry only: it must never
// gate the render/prompt/execute decisions, because a verify run that
// is not a preview still has to execute.
type runFlags struct {
DryRun bool
Diff bool
Yes bool
Verify bool
}

func defaultDeps() Deps {
Expand Down Expand Up @@ -118,8 +126,11 @@ versioned step.

Use --dry-run to preview the plan without writing anything; --diff to
also print per-file unified diffs. Both modes exit before any remote
write. --yes auto-confirms the post-plan prompt and skips any
interactive directory prompt.
write. --verify forces a remote round-trip that surfaces BASE-vs-REMOTE
divergence (composes with --dry-run and --diff); on an applying run it
also re-fetches after upload to verify what was uploaded. --yes
auto-confirms the post-plan prompt and skips any interactive directory
prompt.

Run 'dr artifact code init <artifact-id>' first to link a project
directory to an artifact.
Expand All @@ -129,6 +140,7 @@ Example:
dr artifact code sync --dry-run
dr artifact code sync --diff
dr artifact code sync --yes
dr artifact code sync --verify
dr artifact code sync --output-format json`,
PreRunE: auth.EnsureAuthenticatedE,
RunE: func(cmd *cobra.Command, _ []string) error {
Expand All @@ -144,6 +156,15 @@ Example:
c.Flags().Bool("dry-run", false, "Show plan, no writes.")
c.Flags().Bool("diff", false, "Show plan + per-file unified diffs, no writes.")
c.Flags().BoolP(cli.YesFlagName, "y", false, "Skip interactive prompts; auto-confirm.")

// Transient flag, like --yes: read straight from cobra in parseRunFlags,
// never bound into viper and never persisted to drconfig.yaml.
//
// It must not join the dry-run/diff mutual-exclusion group: verify is a
// diagnostic intensity switch, not a third preview mode, and a verify
// run without --dry-run still applies its plan.
c.Flags().Bool("verify", false, "Force a remote round-trip to surface BASE-vs-REMOTE divergence; on an applying run also re-fetch after upload to verify what was uploaded.")

c.MarkFlagsMutuallyExclusive("dry-run", "diff")

telemetry.TrackWith(c, func(cmd *cobra.Command, _ []string) map[string]any {
Expand All @@ -153,6 +174,7 @@ Example:
"dry_run": flags.DryRun,
"diff": flags.Diff,
"yes": flags.Yes,
"verify": flags.Verify,
"output_format": string(outputFormat),
}
})
Expand All @@ -174,7 +196,12 @@ func runSync(cmd *cobra.Command, outputFormat outputformat.OutputFormat, deps De
return errors.New("not linked: run 'dr artifact code init <artifact-id>' first")
}

engine, err := deps.NewEngine(dir, sync.Options{DryRun: flags.DryRun, ShowDiffs: flags.Diff, Yes: flags.Yes})
engine, err := deps.NewEngine(dir, sync.Options{
DryRun: flags.DryRun,
ShowDiffs: flags.Diff,
Yes: flags.Yes,
Verify: flags.Verify,
})
if err != nil {
return err
}
Expand All @@ -195,6 +222,8 @@ func runSync(cmd *cobra.Command, outputFormat outputformat.OutputFormat, deps De
format.StateNotice(cmd.ErrOrStderr(), engine.StateMigrationNotice())
format.StateNotice(cmd.ErrOrStderr(), engine.IgnoreFileNotice())
format.StateNotice(cmd.ErrOrStderr(), engine.LockedNotice())
format.StateNotice(cmd.ErrOrStderr(), divergenceSummaryNotice(flags, plan, engine.Divergences()))
format.StateNotice(cmd.ErrOrStderr(), skippedSymlinkSummaryNotice(engine.SkippedSymlinks()))

if engine.StaleRollbackRestored() {
fmt.Fprintln(cmd.ErrOrStderr(), tui.DimStyle.Render("Recovered from interrupted sync. Working tree restored."))
Expand All @@ -205,18 +234,120 @@ func runSync(cmd *cobra.Command, outputFormat outputformat.OutputFormat, deps De

// parseRunFlags reads the cobra flags once and folds the
// DATAROBOT_CLI_NON_INTERACTIVE env-var override into Yes, so the
// downstream helpers see a single source of truth.
// downstream helpers see a single source of truth. Every flag here is
// transient: read directly from cobra, never through viper.
func parseRunFlags(cmd *cobra.Command) runFlags {
dryRun, _ := cmd.Flags().GetBool("dry-run")
diff, _ := cmd.Flags().GetBool("diff")

verify, _ := cmd.Flags().GetBool("verify")

return runFlags{
DryRun: dryRun,
Diff: diff,
Yes: cli.IsNonInteractive(cmd),
Verify: verify,
}
}

// divergenceSummaryNotice renders the one-line --verify summary that runs
// alongside the other state notices: what diverged and what happens next.
// The per-path detail (with hashes) is logged from Phase 2 itself, so this
// summary stays stream-agnostic and lists paths up to DivergenceNoticeBound,
// summarizing any remainder as a count.
//
// The "what happens next" half is mode- and plan-aware because the honest
// answer differs by what the run actually does: a preview writes nothing, an
// empty-plan repair reconciles through the Phase 6 manifest rewrite rather
// than through plan rows, and only a non-empty applying plan reconciles the
// divergences through its rows.
func divergenceSummaryNotice(flags runFlags, plan *sync.SyncPlan, divergences []sync.Divergence) string {
if len(divergences) == 0 {
return ""
}

paths := make([]string, len(divergences))

for i, d := range divergences {
paths[i] = d.Path
}

// Bound the joined path list the same way the per-symlink summary does:
// individual names up to DivergenceNoticeBound, then a count. The full
// list always travels in the plan JSON.
shown := paths

if len(paths) > sync.DivergenceNoticeBound {
shown = paths[:sync.DivergenceNoticeBound]
}

list := strings.Join(shown, ", ")

if len(paths) > sync.DivergenceNoticeBound {
list += fmt.Sprintf(", and %d more", len(paths)-sync.DivergenceNoticeBound)
}

head := fmt.Sprintf(
"--verify found %d divergence(s) between manifest.json (BASE) and the server (REMOTE): %s.",
len(divergences), list)

switch {
case flags.DryRun:
return head + " This was a preview; nothing was written. Run without --dry-run to reconcile."
case flags.Diff:
return head + " This was a preview; nothing was written. Run without --diff to reconcile."
case plan.IsEmpty():
return head + " The plan is empty, but manifest.json is being rewritten from the server's state to repair them."
default:
return head + " The plan reconciles them."

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.

[medium] This is printed in runSync before the conflict prompt, so quitting at the prompt, or JSON mode returning early on conflicts without --yes, leaves "The plan reconciles them." on stderr for a run that executed nothing. The dry-run, diff and empty-plan branches are all careful about exactly this; the conflict-abort mode is the one that got missed.

}
}

// skippedSymlinkSummaryNotice renders the one-line summary of skipped
// symlinks that runs alongside the other state notices. The per-symlink
// detail (with kind-specific wording) is logged from Phase 2 itself, so
// this summary stays stream-agnostic and names each symlink with its kind.
// The prose is bounded at SymlinkNoticeBound entries; the structured JSON
// field on the plan document carries every symlink regardless.
func skippedSymlinkSummaryNotice(symlinks []sync.SkippedSymlink) string {
if len(symlinks) == 0 {
return ""
}

// Sort by path for deterministic output across runs.
sorted := make([]sync.SkippedSymlink, len(symlinks))
copy(sorted, symlinks)

sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })

shown := sorted

if len(sorted) > sync.SymlinkNoticeBound {
shown = sorted[:sync.SymlinkNoticeBound]
}

parts := make([]string, len(shown))

for i, s := range shown {
kind := "file"
if s.IsDir {
kind = "directory"
}

parts[i] = fmt.Sprintf("%s (%s)", s.Path, kind)
}

if len(sorted) > sync.SymlinkNoticeBound {
return fmt.Sprintf(
"%d symlink(s) were not uploaded or synced: %s, and %d more.",
len(sorted), strings.Join(parts, ", "), len(sorted)-sync.SymlinkNoticeBound)
}

return fmt.Sprintf(
"%d symlink(s) were not uploaded or synced: %s.",
len(sorted), strings.Join(parts, ", "))
}

// finishSync handles the render → optional prompt → execute → render
// tail of the command. Pulled out so runSync's early-return paths
// (auth, lock, plan errors) stay flat.
Expand All @@ -227,11 +358,11 @@ func finishSync(cmd *cobra.Command, engine engineRunner, plan *sync.SyncPlan, ou
return finishJSON(engine, plan, out, flags)
}

if err := renderHumanPlan(cmd, engine, plan, flags.Diff); err != nil {
if err := renderHumanPlan(cmd, engine, plan, flags); err != nil {
return err
}

if flags.DryRun || flags.Diff || plan.IsEmpty() {
if skipsExecute(flags, plan, engine.Divergences()) {
return nil
}

Expand All @@ -255,20 +386,45 @@ func finishSync(cmd *cobra.Command, engine engineRunner, plan *sync.SyncPlan, ou
}

// renderHumanPlan prints the plan and optional per-file diffs.
func renderHumanPlan(cmd *cobra.Command, engine engineRunner, plan *sync.SyncPlan, diffFlag bool) error {
func renderHumanPlan(cmd *cobra.Command, engine engineRunner, plan *sync.SyncPlan, flags runFlags) error {
out := cmd.OutOrStdout()

// An empty plan normally prints "Up to date." — truthful when disk and
// server genuinely agree. On an applying run whose --verify findings
// force the empty-plan repair, though, Execute is about to rewrite
// manifest.json from the server's state, and "Up to date." immediately
// before that rewrite would claim nothing is being fixed. Previews keep
// the ordinary line: nothing is written, so the divergence summary's
// preview wording is what carries the honesty there.
if plan.IsEmpty() && len(engine.Divergences()) > 0 && !flags.DryRun && !flags.Diff {
return display.PrintEmptyPlanRepair(out)
}

if err := display.PrintPlan(out, plan); err != nil {
return err
}

if !diffFlag {
if !flags.Diff {
return nil
}

return display.PrintDiffs(out, plan, engine.Fetcher())
}

// skipsExecute encapsulates the decision to stop after rendering the plan.
// A preview always stops. An empty plan stops too — except when a --verify
// run recorded BASE-vs-REMOTE divergences: then Execute must run, because
// Phase 5 no-ops on an empty plan and the Execute is what drives Phase 6's
// rewrite of the poisoned manifest from the real remote. Skipping Execute
// there is how the poison survives the run that caught it.
func skipsExecute(flags runFlags, plan *sync.SyncPlan, divergences []sync.Divergence) bool {
if flags.DryRun || flags.Diff {
return true
}

return plan.IsEmpty() && len(divergences) == 0
}

// shouldPromptConflicts encapsulates the decision: prompt only when
// the user has not passed --yes and the plan actually has conflicts.
func shouldPromptConflicts(plan *sync.SyncPlan, yes bool) bool {
Expand All @@ -283,11 +439,14 @@ func shouldPromptConflicts(plan *sync.SyncPlan, yes bool) bool {
// plan is emitted and no Execute is run, so callers can inspect the
// plan and re-invoke with --yes if they want to proceed.
func finishJSON(engine engineRunner, plan *sync.SyncPlan, out io.Writer, flags runFlags) error {
if err := display.RenderPlanJSON(out, plan, engine.LockedNotice() != ""); err != nil {
if err := display.RenderPlanJSON(out, plan, engine.LockedNotice() != "", engine.Divergences(), engine.SkippedSymlinks()); err != nil {
return err
}

if flags.DryRun || flags.Diff || plan.IsEmpty() {
// Same skip rule as the human path, including the empty-plan exception:
// divergence findings from --verify mean Execute must run so Phase 6
// repairs the manifest, even though the plan itself has no rows.
if skipsExecute(flags, plan, engine.Divergences()) {
return nil
}

Expand Down
Loading