Repository navigation
feat(cli): read a page body back, and append to one without destroying it - #36
Conversation
2dbf3b6 to
6f5ddb2
Compare
…g it
The tool could write a page body but never read one. Combined with
--body-file's replace semantics -- every run makes the body equal to the
file, no undo, no lock -- the only way to see what a run was about to
destroy was to open the page in a browser. That also left the agent
skill's own "read before you write" rule stated but inapplicable to the
body: there was nothing to read it with.
Adds two capabilities, both on Notion's native Markdown API:
get --ticket X --body read the body back as Markdown
get --ticket X --body-only the body alone, so > notes.md is valid
set --ticket X --append-file add to the end, deleting nothing
Reading is one call to GET /v1/pages/{id}/markdown -- no recursion into
child blocks, and block types this tool cannot itself build (tables,
callouts, toggles) still read back correctly. Appending goes through
insert_content, which the reference marks legacy while the guide
documents as current; calling it settled the question before any code
was written (200, no deprecation headers, existing content preserved).
Everything is additive. --body-file keeps its code, its tests and its
sub-page skip untouched, and get --json without --body keeps the exact
flat shape every existing script parses -- pinned by a test.
Three things the API does that the obvious implementation gets wrong:
- An append is NOT idempotent, so it uses doRejectRetryable: a 500 comes
back as ErrAmbiguousWrite with no retry, because retrying blindly
duplicates content on a real page. A 429 is a refusal and is retried.
- Empty content returns 200 and silently does nothing, so the empty-file
check is the only thing between a user and a successful-looking no-op.
It runs again after --expand, since a file holding just {{ticket}} is
non-empty on disk and can still expand to nothing.
- A failed append reports appended:false rather than borrowing the
replace path's counters, which would claim "0 blocks written" about an
operation that never counted blocks.
Docs carried the same class of bug -- text that was true until this
feature made it false:
- README said no command reads a body back and there is no append mode,
in the one paragraph someone reads before overwriting a page.
- SKILL.md told agents "you cannot see what you are about to delete" and
that "I ran get first" is not a check on the body. It is now.
- Its failure table said to re-run an ambiguous write to converge --
sound for a replace, and the advice that duplicates a note on an
append.
- Exit 3 after get --body can mean "the row exists but its body would
not render", so treating it as "no such ticket" and following with an
upsert would duplicate a row that is still there.
- apply has no append_file field, so "use apply instead of looping" and
"prefer --append-file" collided; the gap is now stated and pinned by a
test that fails if the manifest ever grows the field.
Includes the design spec and implementation plan under docs/superpowers.
6f5ddb2 to
8949753
Compare
marcoarnulfo
left a comment
There was a problem hiding this comment.
Thanks for this — the shape is right, and the part I expected to be wrong is not. A non-idempotent write going through doRejectRetryable while the read keeps the full retry policy is exactly the split I would have asked for, the empty-content guard running again after --expand is the kind of thing you only get by actually reading the API's behaviour rather than assuming it, and TestGetWithoutBodyKeepsLegacyJSONShape pins the one piece of backward compatibility that scripts depend on. §10 of the design doc settling the insert_content legacy-vs-current contradiction with a real call, before writing code, is the right order of operations.
I checked out the branch and verified the points below with throwaway tests rather than by reading alone. go test ./... -race, gofmt, go vet and staticcheck are all green on my machine too.
Two things I would like fixed before merge.
1. The ambiguous-append message contradicts itself — internal/service/service.go:255
appendBody wraps with %w, which drags the sentinel's own text (errors.go:17) into the final string. Stubbing a 502 on the PATCH, this is what lands in body.error:
the append may or may not have been applied; check the page before re-running,
because re-running an append that did land adds the content twice:
notion: write outcome unknown; re-run to converge: notion: bad_gateway (502): upstream
Your sentence says don't re-run. The sentinel's says re-run to converge, and it says it last. The primary consumer of this CLI is an agent driving it through SKILL.md — the same audience whose failure table you corrected in this very PR for giving that advice on an append. Leaving it in the error string reintroduces it one layer down.
A dedicated error type in service with its own Error() and an Unwrap() to ErrAmbiguousWrite keeps errors.Is and the exit code intact without propagating the wording.
2. The 1 MiB response cap is wrong for this endpoint — internal/notion/markdown.go:52,86
maxResponseBodyBytes is 1 MiB and its comment reasons that "real Notion payloads are a few KB at most" — true while every endpoint we called was paginated. This one is not: it returns the whole body in one response, and Notion only truncates around 20,000 blocks. Two consequences, both reproduced against httptest:
- A successful append reported as ambiguous. The PATCH returns the full updated markdown. Once the page is past the cap,
doOncegets its 200, discards the body for size, and returns a non-APIError, whichdoRejectRetryabletreats as a transport error →ErrAmbiguousWrite,appended:false. The append landed. On a page that grows — a log, a running set of notes, the exact use case--append-fileexists for — this becomes permanent: every append from then on looks like it failed. Combined with (1), the remedy the message suggests is the one that duplicates. get --bodycannot read a large page at all: exit 1 withnotion: response exceeds maximum size of 1048576 bytes, and no way around it.
For the PATCH, out=nil is enough — service.go:249 discards the response already, and the 200 alone settles the outcome. The GET needs a cap of its own, well above 1 MiB.
Smaller things
body.go:187—appended:falseflattens "did not happen" and "outcome unknown" into one value; the ambiguity survives only in the prose oferror. A caller branching on that boolean re-runs. An explicit"ambiguous": truewhenerrors.Is(err, ErrAmbiguousWrite)would settle this together with (1).service.go:249—AppendPageMarkdown's doc sells "no follow-up GET needed" because the PATCH returns the resulting page, but the only caller throws that away, including theTruncated/UnknownBlockIDsthat the read path surfaces as warnings. Either use them or soften the doc comment.
Test gaps
The new tests are good where they exist — httptest throughout, error paths covered, and pinning both "a 500 is not retried" and "a 429 is" is exactly right. What is missing:
upsert --append-fileis never exercised end to end. Every append test goes throughset, so the create→append path and the dry-run reportingappend_bytesnever run through the real command.get --bodywhen the markdown GET fails — exit code and, more importantly, an empty stdout under--json, where a partial write would corrupt a pipe.- The 1 MiB
--append-filelimit (body.go:78). Inherited gap:--body-filehas none either. plan.append_bytesis only asserted in its human-readable line; the JSON key is public API.
Happy for you to take all of the above in this PR rather than a follow-up — none of it is large, and (1) and (2) share the same failure story. Ping me when it's pushed and I'll re-review.
…able Two findings from review, one failure story. A page's whole body comes back in one /markdown response, but the client capped every response at 1 MiB — a ceiling sized for paginated payloads. Past it the PATCH's 200 was discarded for size and surfaced as a non-*APIError, which doRejectRetryable cannot tell from a transport failure, so an append that DID land was reported ambiguous and appended:false. Permanent on exactly the append-only pages --append-file exists to grow, and get --body could not read such a page at all. The cap is now a property of the type being decoded: pageMarkdownResponse declares 32 MiB, chosen against Notion's own ~20,000-block truncation point, so both markdown calls get it wherever they are made and every other endpoint keeps the 1 MiB default. The message that failure produced then told an agent to do the one thing that duplicates: %w dragged ErrAmbiguousWrite's "re-run to converge" in after our own "check the page before re-running", and it landed last. AmbiguousAppendError owns the wording and unwraps to the sentinel, so errors.Is and the exit code are unchanged. Also: --json reports "ambiguous":true, since appended:false alone reads the same for "Notion refused it" as for "outcome unknown" and a caller branching on it re-runs; and the append now surfaces the response's Truncated/UnknownBlockIDs, worded as the state of the page rather than borrowing the read path's "this body is truncated". Tests cover the gaps review named: upsert --append-file end to end over the create path, get --body failing under --json with stdout left empty, the --append-file size limit, and plan.append_bytes as a JSON key. Each regression test was confirmed to fail against the code before the fix.
…ody's --append-file shared --body-file's 1 MiB cap, but the two limits are not the same kind of number. A body is parsed into blocks and sent through splitIntoRequests, which batches under maxBytesPerRequest (450KB), so the file is a TOTAL. An append is sent verbatim as one insert_content payload, so the file IS the request -- and Notion caps a payload at 1000 block elements / 500KB (Request limits > Size limits). Anything between 500KB and 1 MiB therefore passed our check and was refused by the API after the request went out. maxAppendFileBytes is now 450KB, matching maxBytesPerRequest rather than sitting on the 500KB line: the JSON envelope and the escaping of Markdown into a JSON string both add to what the file itself weighs. The error says which Notion limit is behind the cap and what to do instead. Also corrects maxMarkdownResponseBytes' rationale. It claimed 32 MiB was "chosen against Notion's own truncation point"; checking the docs, Notion publishes NO maximum for that response at all -- the 500KB figure governs what you send. The number is unchanged but the comment now shows the derivation (20,000 blocks x ~500 bytes ~= 10 MB, 3x headroom) and says why erring high is the conservative direction here: this ceiling protects nothing, and too low a value turns a landed append into a reported failure. Tests: the append cap asserted against Notion's documented 500KB, a file between the two caps proven legal as a body and refused as an append, loadAppendBody at and over the cap, and --body-file's own limit, which review noted had no test either.
Self-review of 1f0967a found the 450KB file cap it introduced does not actually prevent what it claims to. JSON escaping inflates by a PROPORTION, not a constant: every newline in the Markdown becomes two bytes inside the JSON string. A file exactly at 450KB made of short lines -- a changelog, a bullet list -- serializes to a 691,283-byte PATCH, 38% past Notion's 500,000-byte payload limit, and is refused by the API after the request goes out. That is the failure the cap existed to stop. A fixed margin cannot cover a proportional overhead. Worse, --expand ran AFTER the size check and only emptiness was re-tested, so a legal file could grow past the limit before being sent: a file of "{{date}} " units at the cap left the client as a 563,283-byte payload. Both proved with throwaway tests before changing anything. The check now builds the payload and measures it, after expansion, against Notion's real 500KB. maxAppendFileBytes survives only as a cheap first gate so an absurd file is rejected without serializing it. The error quotes the request size and says why it exceeds the file, which otherwise reads as an arithmetic error. underlyingCause is hardened too. It stripped the sentinel only from the exact fmt.Errorf("%w: %w", ...) doRejectRetryable produces today; a single innocuous wrap added anywhere on that path would have silently restored "re-run to converge" -- the one sentence AmbiguousAppendError exists to keep from an agent. It now descends both wrap forms and, as a last resort, refuses any text quoting the advice. Also from the review: assert ExitOK rather than "not ExitUsage" in the between-the-caps test, drop a dead errors.Is after a Fatal, and pin the documented 500KB to the constant in all three docs, which stated it in prose only.
marcoarnulfo
left a comment
There was a problem hiding this comment.
All eight points are resolved. I checked the code rather than the commit messages, and for each regression test I confirmed it actually fails against 8949753 — the messages on this branch are persuasive enough that taking them at face value would have been the wrong way to re-review it. gofmt, go vet, staticcheck and go test ./... -race are green here.
Two things are worth saying before the nits.
You did not take my suggestion on (2), and you were right not to. I proposed out=nil on the PATCH; you made the cap a property of the type being decoded (responseLimitFor, client.go:44-50, with pageMarkdownResponse declaring its own at markdown.go:67-71). out=nil would have thrown away the response — which is precisely what I asked you to stop doing in the next item of the same review. Your version closes both. It also puts the ceiling where the knowledge is, so a future caller of that endpoint gets it without knowing to ask.
underlyingCause went further than the finding required. I asked for a type that owns its wording; you also hardened the stripping against wrap shapes that do not exist in this codebase yet, with a text guard as the floor. I tried to construct a wrapping that puts "re-run to converge" back in front of an agent and could not do it with anything reachable from here. The worst case is losing a wrapper's prefix — lossy in the safe direction.
Six things below. The first two I would like in this PR; they are a line each. The rest are fine as follow-ups.
1. The size in the pre-gate's message is not the request size — internal/cli/body.go:104-105
The cheap first gate passes len(raw) into appendTooLargeMessage, whose template says "builds a %d-byte request". At that call site it is the file's size, and the request is strictly larger.
That would be a small wording slip on its own. What makes it worth fixing is readBodySource (body.go:172-177), which stops at one byte past the larger cap. So past 1 MiB the number is neither the file nor the request:
$ notion-track set --ticket TASK-231 --append-file 2mb.md
append file 2mb.md builds a 1048577-byte request, over Notion's 500000-byte limit …
1,048,577 is the read ceiling. The file is 2 MiB, the request would be larger still. An agent reading that number and concluding "split it in two 524 KB halves" produces two more refusals.
The head commit here is titled "measure the append request, not the file it came from", and this is the one branch that still does not. Give the pre-gate its own sentence in file terms and leave appendTooLargeMessage to the measured rejection at :129-131, where it is accurate.
2. A BOM-only file gets through the empty guard — internal/cli/body.go:107,120
strings.TrimSpace does not strip U+FEFF. It has not been in White_Space since Unicode 6.3, so unicode.IsSpace excludes it, and a file whose entire content is a BOM is "non-empty" to both checks.
That is not a hypothetical file. It is what an editor on Windows writes when you save an empty file as UTF-8-with-BOM. It reaches AppendMarkdown as "", and if Notion treats it as empty content — the 200-and-do-nothing you documented in §10.e and built the guard around — the run reports appended:true for a page that did not change. That is the silent success the guard exists to prevent, arriving through the guard.
strings.TrimPrefix(s, "") before the checks. Zero-width space has the same property if you want to be thorough about it.
3. The invariant that justifies maxAppendFileBytes is false — internal/cli/body.go:43-48
The comment argues the pre-gate can sit exactly on the payload limit because "no file smaller than that can ever produce a payload under it, and escaping only ever grows the content". Escaping does. Expansion does not, and the pre-gate runs before it (:104 vs :110): {{ticket}} resolves to the empty string when the row was addressed by --page-id, which is the same shrink-to-nothing case the re-check at :120 exists for.
So a file of placeholders just over 500KB is refused with the message from (1), though it would have built a payload of a few dozen KB. Pathological input, and I am not asking you to support it — but the comment two lines above the constant asserts something the code below it contradicts, and on this branch that is the kind of thing that turns into a wrong fix later. Either say the gate is deliberately conservative before expansion, or move it after.
4. SKILL.md:268,434 — "a timeout or 5xx" is not what the code classifies
rejectedByServer (blocks.go:117-121) retries 429, 503 and 529 as certain refusals; only 500/502/504 and transport errors become ambiguous. README.md gets this right ("a transport error, a 502/504") and the skill does not.
It errs safe — it can only make an agent treat a clean refusal as ambiguous, never the reverse — which is why it is down here and not above. But the skill is the document that gets executed, so it is the one that should be exact.
5. The plan and design merge stating a limit the same PR rejects
docs/superpowers/plans/2026-08-04-notion-track-page-content.md:1324,1406,1779 and specs/2026-08-04-notion-track-page-content-design.md:174,238 specify 1 MiB for --append-file, reusing maxBodyFileBytes. What shipped is loadAppendBody measuring the serialized payload against 500,000. The design also predates ambiguous:true and the 32 MiB response cap, and describes GetBody as resolving addressing where the shipped one takes a resolved page id.
Seven plan/spec pairs are already on main, so including them is the convention and I am not asking you to drop these. But these are the first to land carrying a number their own PR's code and user-facing docs refuse, and the next person to read them — or the next agent — will read them as current. One line at the top pointing at the final constants closes it.
6. The new JSON keys are not pinned to the skill — internal/cli/skilldoc_test.go:128
TestGetJSONFieldsAreDocumented iterates pageJSON only. Outside it: markdown, truncated, unknown_block_ids (get.go:171-179) and the emitWrite keys appended, ambiguous (body.go:254-299). All five have functional tests — but those get updated in the same edit as a rename, and then nothing forces SKILL.md to change with them. That is the drift the skilldoc tests exist to catch, and these keys are outside the net. Same for the "allows 1 MiB" claim at SKILL.md:260: the 500KB figure is pinned to its constant, maxBodyFileBytes is not.
Push (1) and (2) and I will approve. The others can be a follow-up, or this PR if you would rather close them while the context is fresh.
…e as empty
Both blockers from the second review, plus the four follow-ups, since the
context is fresh.
1. The cheap pre-gate passed len(raw) to appendTooLargeMessage, whose
template says "builds a %d-byte request". readBodySource stops one byte
past the larger cap, so for a 2 MiB file that number was 1048577 -- the
read ceiling, neither the file nor the request. An agent halving it gets
two more refusals. The gate now has its own sentence, in file terms
("is at least N bytes"), and appendTooLargeMessage is left to the
measured rejection where it is accurate.
2. strings.TrimSpace does not strip U+FEFF: it left White_Space in Unicode
6.3, so a file holding only a BOM -- what an editor on Windows writes
for an empty file saved as UTF-8-with-BOM -- read as content. It would
reach Notion, which answers 200 and does nothing, and the run would
report appended:true for a page that did not change: the silent no-op
the empty guard exists to prevent, arriving through the guard. isBlank
strips U+FEFF and U+200B before both emptiness checks, everywhere
rather than only at the front, since --expand can strand one.
Follow-ups, all from the same review:
3. The comment justifying maxAppendFileBytes claimed "escaping only ever
grows the content". Escaping does; expansion does not, and the gate runs
before it. Says it is deliberately conservative instead, and why moving
it is not worth it.
4. SKILL.md called an ambiguous failure "a timeout or 5xx". 503 and 529 are
5xx and are retried as clean refusals; only 500/502/504 and transport
errors are ambiguous. README had it right, the skill is the one that
gets executed.
5. The plan and design doc specify 1 MiB for --append-file, which this
PR's own code refuses. Both now open with what shipped instead.
6. TestGetJSONFieldsAreDocumented walks pageJSON only, leaving markdown,
truncated, unknown_block_ids, appended and ambiguous outside the net
that exists to catch exactly this drift. Pinned, along with the skill's
1 MiB --body-file claim.
Each regression test confirmed to fail against 46b0106 before the fix.
marcoarnulfo
left a comment
There was a problem hiding this comment.
Both blockers are fixed, and the four follow-ups came with them. Approving.
I checked the two blockers at the boundaries rather than by reading:
The pre-gate message. At exactly 500,000 bytes the pre-gate stays silent and the measured gate quotes the real serialized payload — "builds a 500083-byte request", which is what appendPayloadBytes returns. At 500,001 it is "is at least 500001 bytes". At 2 MiB, past the read ceiling, it is "is at least 1048577 bytes". "builds a" no longer appears on the branch that cannot know it. The floor phrasing is the honest one there, and you used it.
isBlank. Built on strings.NewReplacer, so it is position-independent — front, middle, end, several. What I went looking for was the failure mode a fix like this invites: a BOM sitting in front of real content being swallowed as blank. It is not. isBlank only tests; :153 still sends the original raw, byte for byte. Applied to both the pre-expand and post-expand checks. U+2060, U+180E, a UTF-16LE BOM and invalid UTF-8 remain outside it — all outside the old guard too, so nothing regressed, and none of them is the case that shows up in practice.
The supersession headers on the plan and design read as I hoped: what shipped, at the top, with the bodies left as the record of what was planned.
One small thing, and one that is not yours.
SKILL.md:259 — "Over the limit is exit 2 before any request, with the request size named" is now true only of the measured branch. Past 500,000 bytes on disk the CLI names a floor on the file size, which is the point of the fix. A subordinate clause; the skilldoc net you added in this same commit does not cover that sentence. Fix it here or in your next PR, either is fine — it is not holding the merge.
--body-file has the same BOM bypass, and there it wipes the body. Not yours: loadBody's guard is byte-identical on main, and the append path is what I scoped you to. But the chain is worth knowing since you wrote the helper that fixes it — BOM-only file passes TrimSpace, goldmark returns 0 blocks, ValidateAppendable returns nil on an empty slice, splitIntoRequests emits no batch so no PATCH goes out, and replaceBody deletes every non-sub-page child anyway. Body gone, nothing written, exit 0. Filed as #37 with isBlank named as the fix. If you want it after this merges, say so and it is yours.
Thanks for the three rounds. The failure story in each commit message doing the work of the justification made this quick to re-review — I could go straight at the claim instead of reconstructing why the change was there. Two notes for next time, both small: "each regression test confirmed to fail against 46b0106" was true of three of the six — the other three are guards and doc pins, which is correct and worth saying rather than rounding off. And a comment that asserts an invariant, like the "escaping only ever grows the content" one, is worth a second look whenever the code around it moves; that is twice now that the prose outlived what the code does.
🇬🇧 English
Summary
The tool could write a page body but never read one. Combined with
--body-file's replace semantics — every run makes the body equal to the file, no undo, no lock — the only way to see what a run was about to destroy was to open the page in a browser. That also leftSKILL.md's own "read before you write" rule stated but inapplicable to the body: there was nothing to read it with.This adds both halves, on Notion's native Markdown API:
Reading is one call to
GET /v1/pages/{id}/markdown— no recursion into child blocks, and block types this tool cannot itself build (tables, callouts, toggles) still read back correctly. Appending usesinsert_content, which/referencemarks legacy while the guide documents as current; that was settled with a real call before any code was written (200, no deprecation headers, existing content preserved) — see §10 of the design doc.Everything is additive:
--body-filekeeps its code, its tests and its sub-page skip untouched, andget --jsonwithout--bodykeeps the exact flat shape existing scripts parse, pinned by a test.Three API behaviours the obvious implementation gets wrong
doRejectRetryable: a 500 returnsErrAmbiguousWritewith no retry, because retrying blindly duplicates content on a real page. A 429 is a refusal and is retried.--expand, since a file holding just{{ticket}}is non-empty on disk and can still expand to nothing.appended:falserather than borrowing the replace path's counters, which would claim "0 blocks written" about an operation that never counts blocks.Docs carried the same class of bug — text that was true until this feature made it false:
README.md:272said no command reads a body back and there is no append mode, in the one paragraph someone reads before overwriting a page.SKILL.mdtold agents "you cannot see what you are about to delete" and that "I rangetfirst" is not a check on the body. It is now.get --bodycan mean "the row exists but its body would not render", so reading it as "no such ticket" and following with anupsertwould duplicate a row that is still there.applyhas noappend_filefield, so "useapplyinstead of looping" and "prefer--append-file" collided. The gap is now stated and pinned by a test that fails if the manifest ever grows the field.Linked issue
Closes #
Type of change
Checklist
go test ./... -racepassesgofmt,go vet,staticcheckare cleanREADME.mdandREADME.it.mdupdated if behavior changedfeat(cli): ...)Co-Authored-Byin commit messages🇮🇹 Italiano
Riepilogo
Il tool sapeva scrivere il corpo di una pagina ma non leggerlo mai. Insieme alla semantica replace di
--body-file— ogni esecuzione rende il corpo uguale al file, senza undo e senza lock — l'unico modo di sapere cosa si stava per distruggere era aprire la pagina nel browser. Questo lasciava anche la regola «read before you write» diSKILL.mdenunciata ma inapplicabile al corpo: non c'era nulla con cui leggerlo.Questa PR aggiunge entrambe le metà, sull'API Markdown nativa di Notion:
La lettura è una sola chiamata a
GET /v1/pages/{id}/markdown— nessuna ricorsione nei blocchi figli, e i tipi di blocco che questo tool non sa costruire (tabelle, callout, toggle) si rileggono comunque correttamente. L'append usainsert_content, che/referencemarca come legacy mentre la guida documenta come corrente: la contraddizione è stata risolta con una chiamata reale prima di scrivere codice (200, nessun header di deprecazione, contenuto preesistente preservato) — vedi §10 del design doc.Tutto è additivo:
--body-filemantiene codice, test e skip delle sub-page invariati, eget --jsonsenza--bodyconserva esattamente la forma piatta che gli script esistenti già parsano, fissata da un test.Tre comportamenti dell'API che l'implementazione ovvia sbaglia
doRejectRetryable: un 500 torna comeErrAmbiguousWritesenza retry, perché ritentare alla cieca duplica contenuto su una pagina reale. Un 429 è un rifiuto e viene ritentato.--expand, dato che un file contenente solo{{ticket}}è non vuoto su disco e può comunque espandersi a nulla.appended:falseinvece di riusare i contatori del replace, che direbbero «0 blocchi scritti» di un'operazione che blocchi non ne conta mai.La documentazione aveva la stessa classe di bug — testo vero fino a quando questa feature lo ha reso falso:
README.md:272affermava che nessun comando rilegge il corpo e che non esiste una modalità append, nell'unico paragrafo che si legge prima di sovrascrivere una pagina.SKILL.mddiceva agli agenti «non puoi vedere ciò che stai per cancellare» e che «ho eseguitogetprima» non è un controllo sul corpo. Ora lo è.get --bodypuò significare «la riga esiste ma il suo corpo non è renderizzabile»: interpretarlo come «ticket inesistente» e farlo seguire da unupsertduplicherebbe una riga che c'è ancora.applynon ha un campoappend_file, quindi «usaapplyinvece di ciclare» e «preferisci--append-file» erano in conflitto. La lacuna è ora dichiarata e fissata da un test che fallisce se il manifest dovesse acquisire quel campo.Issue collegata
Closes #
Tipo di modifica
Checklist
go test ./... -racepassagofmt,go vet,staticcheckpulitiREADME.mdcheREADME.it.mdaggiornati se il comportamento è cambiatofeat(cli): ...)Co-Authored-Bynei messaggi di commit