diff --git a/.changes/harness-ops/fix-plugins-worktree-update-noop-6713a1c4.md b/.changes/harness-ops/fix-plugins-worktree-update-noop-6713a1c4.md new file mode 100644 index 0000000000..f5365f4a88 --- /dev/null +++ b/.changes/harness-ops/fix-plugins-worktree-update-noop-6713a1c4.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- `/harness-ops:plugins` no longer reports a no-op update as a success. `fleet-state.sh` marks each project or local record `linkedWorktree` and labels a lagging one in a linked git worktree `update ineffective; uninstall clears it`, keeping it in the divergence count. `sync` reports an "already at the latest version" update whose project record is still below the catalog as a failed row. `converge` offers linked-worktree rows only the uninstall strategy and counts an update whose re-read record did not move as a failure. diff --git a/plugins/harness-ops/skills/plugins/context/converge.md b/plugins/harness-ops/skills/plugins/context/converge.md index 664e8cff1d..1549f3bb3f 100644 --- a/plugins/harness-ops/skills/plugins/context/converge.md +++ b/plugins/harness-ops/skills/plugins/context/converge.md @@ -84,14 +84,34 @@ running the bare form for a row whose `projectPath` isn't the current directory mutate, or fail against, the wrong repo's settings. `--keep-data` applies to `uninstall` only: `update` has no such flag, and the uninstall half is a pin removal, never a data deletion. -Two `git worktree` checkouts of one repository pin independently, verified on Claude Code 2.1.228 -by uninstalling one id in a repo's main checkout and observing the worktree's record for the same id -survive untouched. They share one `.git` and one tracked `.claude/settings.json` yet hold separate +Two `git worktree` checkouts of one repository pin independently for `uninstall`, verified on Claude +Code 2.1.228 by uninstalling one id in a repo's main checkout and observing the worktree's record for +the same id survive untouched. They do not behave independently for `update` (see the linked-worktree +section below and [scope-semantics.md](scope-semantics.md)). They share one `.git` and one tracked `.claude/settings.json` yet hold separate `projectPath` records, so never collapse them into one row and never assume converging one clears the other: each needs its own `cd`. Per [scope-semantics.md](scope-semantics.md), the CLI keys `projectPath` on the literal cwd while `fleet-state.sh` matches on the checkout root. That gap is a blind spot in its own right, recorded in [gotchas.md](gotchas.md). +### Linked-worktree rows: no update strategy + +A row whose `scopes[].linkedWorktree` is `true` (the git dir differs from the git common dir; +`fleet-state.sh` reads it from the worktree's `.git` file) never gets the update strategy, proposed or +run. The record behind that decision, and the trigger to recheck it, are in +[scope-semantics.md](scope-semantics.md). Emit it as: + +```text +- @: update is not offered here: "" is a linked worktree, and + its record is at . The uninstall strategy clears it, and edits that worktree's + tracked `.claude/settings.json` (Step 5). +``` + +The row keeps the label `fleet-state.sh` gave it, `update ineffective; uninstall clears it`, and stays +in the divergence count. Offer only the uninstall strategy for it, still confirmed per pin; when the +highest version is at `project`/`local` scope and no uninstall would bring the row current, report the +row with this label and offer nothing. A main checkout, and a submodule (its `.git` file points into +`modules/`, not `worktrees/`), behave as before. + ### Precondition: never emit a `cd` command into a path that is not present Before emitting any `(cd "" && …)` command, check that row's `projectPathPresent` (see @@ -153,8 +173,10 @@ update a sibling worktree's record of the same id instead of the cwd's The intended record is still at . ``` -A record that did not move and no other record that did is reported as "did not move", with the CLI's -output. +A record that did not move and no other record that did is a **failure**, never "ok": report it as +"did not move", with the CLI's output, and count it with the failed rows. This holds whatever the CLI +printed, including "already at the latest version" and `updated from X to Y`, because the exit status +and the CLI's line are not evidence that the record changed. The re-read is the only evidence. ## Step 5: Surface the resulting diff diff --git a/plugins/harness-ops/skills/plugins/context/scope-semantics.md b/plugins/harness-ops/skills/plugins/context/scope-semantics.md index d0dfffaeab..1e0b14c998 100644 --- a/plugins/harness-ops/skills/plugins/context/scope-semantics.md +++ b/plugins/harness-ops/skills/plugins/context/scope-semantics.md @@ -179,6 +179,17 @@ checkout that has a linked worktree holding a record of the same id, showing `up updating the record keyed to the literal cwd. On a confirmed fix, drop the read-back in `converge.md` Step 4 and `sync.md` Step 2 and the `currentProject` filter in `sync-run.sh`. +**A lagging record in a linked worktree is labeled, counted, and cleared by `uninstall`, never +trusted to `update`.** `fleet-state.sh` marks each project/local record `linkedWorktree` and labels a +lagging one `update ineffective; uninstall clears it`; the row stays a divergence, and any update the +run does make is verified by re-reading the record ([converge.md](converge.md) Step 4). `uninstall` +clears it and edits that worktree's tracked settings file ([converge.md](converge.md) Step 5). +**Record**: pointer, the update-strategy results of the 2026-10-12 converge run in +[#6713 (comment)](https://github.com/melodic-software/claude-code-plugins/issues/6713#issuecomment-6125445322); +as-of 2026-10-12. **Recheck trigger**: a Claude Code release note that changes how `update -s project` +treats a record keyed to a linked worktree, or a re-run of that probe (the converge update strategy +against lagging records in linked worktrees, then a re-read) showing the records move. + When reading a record out of a `fleet-state.sh` report, a `project`/`local` record belongs to the run only when its `currentProject` flag is `true`; matching on id and scope alone returns whichever sibling's record comes first. diff --git a/plugins/harness-ops/skills/plugins/context/sync.md b/plugins/harness-ops/skills/plugins/context/sync.md index f92110b75d..9a96465472 100644 --- a/plugins/harness-ops/skills/plugins/context/sync.md +++ b/plugins/harness-ops/skills/plugins/context/sync.md @@ -545,8 +545,12 @@ unlike `converge`. the record flagged `currentProject`, and after the post read it compares the run's own record with the CLI's `updated from X to Y` line: when that line names the version `Y` and the record is still at `X`, the id is reported as a failed row naming the record that moved, never as an Updated row. A line -that names no version leaves the row Updated with direction `unknown`. This check covers this -step only; an `--allow-downgrade` project/local rollback is not compared. +that names no version leaves the row Updated with direction `unknown`. The same re-read catches the +opposite report: when the CLI says "already at the latest version" and the run's own project/local +record is still below the catalog version, the id is a failed row too, never a quiet no-op. For a record +in a linked git worktree ([scope-semantics.md](scope-semantics.md)), the row's reason carries the label +`update ineffective; uninstall clears it` that `fleet-state.sh` puts on the record. This check covers +this step only; an `--allow-downgrade` project/local rollback is not compared. ## Step 3: User-scope update sweep diff --git a/plugins/harness-ops/skills/plugins/scripts/fleet-state.sh b/plugins/harness-ops/skills/plugins/scripts/fleet-state.sh index e64cc9ae19..3a206b82d6 100755 --- a/plugins/harness-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/harness-ops/skills/plugins/scripts/fleet-state.sh @@ -92,6 +92,17 @@ # attached. null means not applicable (a user-scope record, or no # projectPath). # +# Each of those records also carries linkedWorktree: true|false|null — whether +# its projectPath is a linked git worktree (the git dir differs from the git +# common dir: `.git` is a file whose gitdir line points into a `worktrees/` +# directory that holds a `commondir` file). null means not applicable, or the path is not present so nothing +# could be read. `label` is "update ineffective; uninstall clears it" on a +# linked-worktree record whose version is below the catalog's (else below the +# highest any record of the id holds), and null otherwise. In a linked worktree +# `claude plugin update -s project` reports success and leaves the record +# unchanged (#6713), so the record is labeled, never dropped: it stays in +# installed[] and in divergences[], and versionsMatch is unchanged. +# # Output (stdout) with --ids : NOT JSON — one record per line, in the # order the block carries them, and nothing else. Fields are TAB-separated and # the first field is always the fully-qualified `@` id, so @@ -995,7 +1006,37 @@ for pp in ${pp_lines[@]+"${pp_lines[@]}"}; do [[ -n "$pp" ]] || continue pp_fs="${pp//\\//}" if [[ -d "$pp_fs" ]]; then - presence_tsv+="$pp"$'\t'"true"$'\n' + # A third column says whether the directory is a LINKED git worktree: its + # `.git` is a file whose `gitdir:` line points into `/worktrees/` + # (the git dir differs from the git common dir). A submodule's `.git` file is + # also a file but points into `modules/`, so the pattern anchors on a + # `/worktrees/` segment and excludes a later `/modules/` segment (a + # submodule inside a linked worktree). Builtins only, no process per path. + # The common dir's name is not constrained (a bare layout uses `.bare`). + # A `/worktrees/` segment alone also matches a separate-git-dir main checkout + # kept under a `worktrees/` parent, so the git dir must hold the `commondir` + # file git writes only into a linked worktree's admin directory. + pp_linked="false" + if [[ -f "$pp_fs/.git" ]]; then + pp_gitfile="" + IFS= read -r pp_gitfile <"$pp_fs/.git" || true + pp_gitfile="${pp_gitfile//$'\r'/}" + pp_gitfile="${pp_gitfile//\\//}" + case "$pp_gitfile" in + gitdir:*/worktrees/*/modules/*) ;; + gitdir:*/worktrees/*) + pp_gd="${pp_gitfile#gitdir:}" + pp_gd="${pp_gd# }" + case "$pp_gd" in + /* | ?:/*) ;; + *) pp_gd="$pp_fs/$pp_gd" ;; + esac + [[ -f "$pp_gd/commondir" ]] && pp_linked="true" + ;; + *) ;; + esac + fi + presence_tsv+="$pp"$'\t'"true"$'\t'"$pp_linked"$'\n' else presence_tsv+="$pp"$'\t'"false"$'\n' fi @@ -1032,12 +1073,23 @@ PASS2_PROGRAM=' # shellcheck disable=SC2016 # a jq program: every $var is a jq variable PASS3_PROGRAM=' + def triple: + if type == "string" then + ((capture("^v?(?[0-9]+)[.](?[0-9]+)[.](?[0-9]+)") // null) + | if . == null then null else [.x, .y, .z] | map(tonumber) end) + else null end; (. | split("\u001d")) as $parts | ($parts[0] | fromjson) as $ctx | ($parts[1] | split("\n") | map(select(length > 0) | split("\t") | select(length >= 2 and (.[0] | length) > 0) | {key: .[0], value: (.[1] == "true")}) | from_entries) as $pres + # Third column: whether a PRESENT path is a linked git worktree. Absent from a + # path that is not present, so the lookup below answers null, never false. + | ($parts[1] | split("\n") + | map(select(length > 0) | split("\t") | select(length >= 3 and (.[0] | length) > 0)) + | map({key: .[0], value: (.[2] == "true")}) + | from_entries) as $wt # Entries whose source is not a string never reached the payload, so they # are absent from this map entirely — a lookup returns null, the same # fail-open answer as an entry whose manifest could not be read. A manifest @@ -1080,8 +1132,31 @@ PASS3_PROGRAM=' .projectPath as $pp | (if ($pres | has($pp)) then $pres[$pp] else null end) else null end + ), + linkedWorktree: ( + if (.scope == "project" or .scope == "local") and (.projectPath // "" | length) > 0 then + .projectPath as $pp + | (if ($wt | has($pp)) then $wt[$pp] else null end) + else null end ) })) as $installed + # A lagging project/local record in a linked worktree: `claude plugin update` + # reports success there and leaves the record unchanged (#6713), so the record + # is labeled instead of dropped. It stays in installed[] and in divergences[], + # and the label never changes versionsMatch. "Lagging" is measured against the + # catalog version when it parses, else the highest version any record of the id + # holds; a version that does not parse is never labeled. + | ($installed | group_by(.id) + | map({key: .[0].id, value: ([.[].version | triple | select(. != null)] | max)}) + | from_entries) as $highest + | ($installed | map( + . as $r + | (($cv[$r.id] | triple) // $highest[$r.id]) as $ref + | $r + {label: ( + if $r.linkedWorktree == true and ($r.version | triple) != null + and $ref != null and ($r.version | triple) < $ref + then "update ineffective; uninstall clears it" else null end)} + )) as $installed | ([$catalog[0].plugins[]?.name // empty] | unique) as $catalog_names | ($catalog_names | map(. + "@" + $name)) as $catalog_ids | ([$installed[].id] | unique) as $installed_ids @@ -1137,7 +1212,7 @@ PASS3_PROGRAM=' | map(select(length > 1)) | map({ id: .[0].id, - scopes: map({scope, version, projectPath, projectPathPresent}), + scopes: map({scope, version, projectPath, projectPathPresent, linkedWorktree, label}), versionsMatch: ((map(.version) | unique | length) == 1) })) as $divergences | { diff --git a/plugins/harness-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/harness-ops/skills/plugins/scripts/fleet-state.test.sh index d7dc000130..2635939111 100755 --- a/plugins/harness-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/harness-ops/skills/plugins/scripts/fleet-state.test.sh @@ -1719,6 +1719,91 @@ assert_eq "projectPathPresent: an absent path never suppresses the divergence ro assert_eq "projectPathPresent: and the row is still graded on version skew alone" \ "false" "$(jq -r '.divergences[0].versionsMatch' <<<"$out" 2>/dev/null)" +# ============================================================================ +# linkedWorktree / label — a lagging record in a linked worktree is labeled, +# stays counted, and a main checkout or a submodule is never mislabeled (#6713). +# ============================================================================ + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +main_dir="$case_dir/main-repo" +wt_dir="$case_dir/linked-wt" +sub_dir="$case_dir/submodule" +bare_wt_dir="$case_dir/bare-repo/main" +sep_dir="$case_dir/sep-checkout" +mkdir -p "$main_dir/.git/worktrees/linked-wt" "$wt_dir" "$sub_dir" \ + "$case_dir/bare-repo/.bare/worktrees/main" "$bare_wt_dir" \ + "$case_dir/worktrees/sep.git" "$sep_dir" +printf '../..\n' >"$main_dir/.git/worktrees/linked-wt/commondir" +printf '../..\n' >"$case_dir/bare-repo/.bare/worktrees/main/commondir" +printf 'gitdir: %s/main-repo/.git/worktrees/linked-wt\n' "$case_dir" >"$wt_dir/.git" +printf 'gitdir: ../.git/modules/submodule\n' >"$sub_dir/.git" +printf 'gitdir: %s/bare-repo/.bare/worktrees/main\n' "$case_dir" >"$bare_wt_dir/.git" +printf 'gitdir: %s/worktrees/sep.git\n' "$case_dir" >"$sep_dir/.git" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg m "$main_dir" --arg w "$wt_dir" --arg s "$sub_dir" --arg bw "$bare_wt_dir" --arg sd "$sep_dir" '{ + version: 1, + plugins: { + "alpha@market1": [ + {scope: "project", projectPath: $w, installPath: "a", version: "0.1.0"}, + {scope: "user", installPath: "u", version: "0.2.0"} + ], + "beta@market1": [ + {scope: "project", projectPath: $m, installPath: "b", version: "0.1.0"}, + {scope: "user", installPath: "u2", version: "0.2.0"} + ], + "gamma@market1": [ + {scope: "project", projectPath: $s, installPath: "c", version: "0.1.0"}, + {scope: "user", installPath: "u3", version: "0.2.0"} + ], + "delta@market1": [ + {scope: "project", projectPath: $w, installPath: "d", version: "0.2.0"}, + {scope: "user", installPath: "u4", version: "0.2.0"} + ], + "epsilon@market1": [ + {scope: "project", projectPath: $bw, installPath: "e", version: "0.2.0"}, + {scope: "user", installPath: "u5", version: "0.2.0"} + ], + "zeta@market1": [ + {scope: "project", projectPath: $sd, installPath: "f", version: "0.2.0"}, + {scope: "user", installPath: "u6", version: "0.2.0"} + ] + } + }' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}, {"name": "beta"}, {"name": "gamma"}, {"name": "delta"}, {"name": "epsilon"}, {"name": "zeta"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +assert_eq "linkedWorktree: true for a worktree whose .git file points into worktrees/" \ + "true" "$(jq -r '.installed[] | select(.id == "alpha@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)" +assert_eq "linkedWorktree: false for a main checkout (.git is a directory)" \ + "false" "$(jq -r '.installed[] | select(.id == "beta@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)" +assert_eq "linkedWorktree: false for a submodule (.git file points into modules/)" \ + "false" "$(jq -r '.installed[] | select(.id == "gamma@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)" +assert_eq "linkedWorktree: true for a bare layout (gitdir points into .bare/worktrees/)" \ + "true" "$(jq -r '.installed[] | select(.id == "epsilon@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)" +assert_eq "linkedWorktree: false for a separate-git-dir main checkout under a worktrees/ parent" \ + "false" "$(jq -r '.installed[] | select(.id == "zeta@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)" +assert_eq "linkedWorktree: null for a user-scope record" \ + "null" "$(jq -r '.installed[] | select(.id == "alpha@market1" and .scope == "user") | .linkedWorktree' <<<"$out" 2>/dev/null)" +assert_eq "label: a lagging linked-worktree record is labeled" \ + "update ineffective; uninstall clears it" \ + "$(jq -r '.installed[] | select(.id == "alpha@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)" +assert_eq "label: the same label rides divergences[].scopes[]" \ + "update ineffective; uninstall clears it" \ + "$(jq -r '.divergences[] | select(.id == "alpha@market1") | .scopes[] | select(.scope == "project") | .label' <<<"$out" 2>/dev/null)" +assert_eq "label: a lagging record in a main checkout is not labeled" \ + "null" "$(jq -r '.installed[] | select(.id == "beta@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)" +assert_eq "label: a lagging record in a submodule is not labeled" \ + "null" "$(jq -r '.installed[] | select(.id == "gamma@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)" +assert_eq "label: a linked-worktree record already at the highest version is not labeled" \ + "null" "$(jq -r '.installed[] | select(.id == "delta@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)" +assert_eq "label: the labeled record stays in the actionable divergence count" \ + "alpha@market1 beta@market1 gamma@market1" \ + "$(jq -r '[.divergences[] | select(.versionsMatch == false) | .id] | sort | join(" ")' <<<"$out" 2>/dev/null)" + # ============================================================================ # user_scope_orphans — the class every other field is structurally blind to. # ============================================================================ diff --git a/plugins/harness-ops/skills/plugins/scripts/sync-run.sh b/plugins/harness-ops/skills/plugins/scripts/sync-run.sh index 755a7ba147..61a5b0337a 100755 --- a/plugins/harness-ops/skills/plugins/scripts/sync-run.sh +++ b/plugins/harness-ops/skills/plugins/scripts/sync-run.sh @@ -1382,12 +1382,33 @@ finalize_moves() { [[ "$old" == "-" ]] && old="" [[ "$new" == "-" ]] && new="" # An id the CLI called already-current is a true noop only when the snapshots - # agree. The post read decides; the version the CLI's line printed (`new`, for a - # noop row) is the cross-check. A post version that differs from `old` means the - # install moved after the pre-mutation read and this call changed nothing: a - # concurrent move, reported as its own row. A version that cannot be read, or - # the two sources disagreeing, stays a noop and is counted as unverified. + # agree and the run's own record took. First, when the run's own project/local + # record is still below the catalog, the CLI judged some other record (a linked + # worktree's update is a no-op, #6713) and the update did not take: a failure, + # never a quiet success, or the next run would count the same divergence again. + # Otherwise the post read decides; the version the CLI's line printed (`new`, + # for a noop row) is the cross-check. A post version that differs from `old` + # means the install moved after the pre-mutation read and this call changed + # nothing: a concurrent move, reported as its own row. A version that cannot be + # read, or the two sources disagreeing, stays a noop and is counted as unverified. if [[ "$result" == "noop" ]]; then + if ((ONLY_INSTALL_MODE == 0)) && [[ "$kind" == "in_repo" && "$scope" != "user" && -f "$post" ]]; then + local ncur ncat nlabel + version_at ncur "$post" "$id" "$scope" + # shellcheck disable=SC2016 # a jq program: every $var is a jq variable + jq_to ncat -r --arg id "$id" '.catalog_versions[$id] // ""' "$post" + # shellcheck disable=SC2016 # a jq program: every $var is a jq variable + jq_to nlabel -r --arg id "$id" --arg sc "$scope" \ + 'first(.installed[]? | select(.id == $id and .scope == $sc and .currentProject == true) + | .label) // ""' "$post" + if [[ -n "$ncur" && -n "$ncat" && "$(version_direction "$ncur" "$ncat")" == "forward" ]]; then + IR_FAILED+=("$(jq -c -n --arg id "$id" --arg sc "$scope" --arg cur "$ncur" --arg cat "$ncat" --arg label "$nlabel" \ + '{id: $id, scope: $sc, rc: 0, output: "", + reason: ("the CLI said already at the latest version, but this project'"'"'s record is still at \($cur) and the catalog reads \($cat): the update did not take" + + (if $label == "" then "" else " (" + $label + ")" end))}')") + continue + fi + fi local post_v="" [[ -f "$post" ]] && version_at post_v "$post" "$id" "$scope" if [[ -z "$old" || -z "$post_v" ]]; then diff --git a/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh b/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh index 5fdec42770..712b3bec68 100755 --- a/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh +++ b/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh @@ -829,6 +829,49 @@ assert_eq "misfire: it is reported as its own failed row" "alpha@market1" \ assert_contains "misfire: the reason names the sibling worktree's path" \ "$(jq -r '.marketplaces[0].in_repo.failed[0].reason' <<<"$out")" "updated \"$sibling\" instead" +# ============================================================================ +# Case: the CLI says "already at the latest version" for a project record that +# is still below the catalog (a linked worktree, #6713). That is a failure, not +# a quiet no-op; a record already at the catalog stays a no-op. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +catalog_plugin "$case_dir" market1 alpha 0.2.0 +catalog_plugin "$case_dir" market1 beta 0.2.0 +ppath=$(norm_path "$case_dir") +printf 'gitdir: %s/main/.git/worktrees/wt\n' "$case_dir" >"$case_dir/.git" +mkdir -p "$case_dir/main/.git/worktrees/wt" +printf '../..\n' >"$case_dir/main/.git/worktrees/wt/commondir" +write "$case_dir/installed_plugins.json" "{ + \"version\": 1, + \"plugins\": { + \"alpha@market1\": [ + {\"scope\": \"project\", \"projectPath\": \"$ppath\", \"installPath\": \"a\", \"version\": \"0.1.0\"} + ], + \"beta@market1\": [ + {\"scope\": \"project\", \"projectPath\": \"$ppath\", \"installPath\": \"b\", \"version\": \"0.2.0\"} + ] + } +}" +write "$case_dir/known_marketplaces.json" "{\"market1\": {\"source\": {\"source\": \"github\", \"repo\": \"e/m\"}, \"installLocation\": \"$case_dir/mkt\", \"lastUpdated\": \"2026-01-01T00:00:00Z\"}}" +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "alpha"}, {"name": "beta", "source": "beta"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {"alpha@market1": true, "beta@market1": true}}' +setup_case "$case_dir" +EXTRA_ENV=(CLAUDE_PROJECT_DIR="$case_dir" CLAUDE_STUB_NOOP_ID="alpha@market1") +out=$(run_sync "$case_dir" --marketplace market1 --install-new none --journal-root "$case_dir/journal") +assert_eq "no-op update: a lagging record is a failed row" "alpha@market1" \ + "$(jq -r '.marketplaces[0].in_repo.failed[0].id' <<<"$out")" +assert_eq "no-op update: it is not an Updated row" "0" \ + "$(jq -r '[.marketplaces[0].in_repo.updated[] | select(.id == "alpha@market1")] | length' <<<"$out")" +assert_contains "no-op update: the reason names the record's version and the catalog's" \ + "$(jq -r '.marketplaces[0].in_repo.failed[0].reason' <<<"$out")" "still at 0.1.0 and the catalog reads 0.2.0" +assert_contains "no-op update: a linked worktree carries the uninstall label" \ + "$(jq -r '.marketplaces[0].in_repo.failed[0].reason' <<<"$out")" "update ineffective; uninstall clears it" +EXTRA_ENV=(CLAUDE_PROJECT_DIR="$case_dir" CLAUDE_STUB_NOOP_ID="beta@market1") +out=$(run_sync "$case_dir" --marketplace market1 --install-new none --journal-root "$case_dir/journal2") +assert_eq "no-op update: a record already at the catalog is not a failure" "0" \ + "$(jq -r '[.marketplaces[0].in_repo.failed[] | select(.id == "beta@market1")] | length' <<<"$out")" + # ============================================================================ # Case: an in-repo record for the plugin providing this skill is a self-update # too, and the stale-project-record rows carry a count PER PATH