buf version: 1.72.0 (also reproduces on 1.71.0; 1.70.0 is unaffected). Built from source at tag v1.72.0, linux/amd64, Go 1.26.4.
Command: buf format repro.proto, applied repeatedly to its own output.
Repro file (repro.proto)
syntax = "proto3";
package example.v1;
import "google/protobuf/descriptor.proto";
message CallerEntry {
string service_account = 1;
}
message AclRule {
repeated CallerEntry callers = 1;
}
extend google.protobuf.MethodOptions {
AclRule acl = 50001;
}
message Req {}
message Resp {}
service ExampleService {
rpc GetThing(Req) returns (Resp) {
option (example.v1.acl) = {
callers: [
{service_account: "beta"},
/* detached comment */
{service_account: "gamma"}
]
};
}
}
Observed
Each pass produces different output, and the comment is destroyed on the second pass.
Pass 1 — the comment moves past the element it annotated, down to the closing bracket, and is dedented to the bracket's level:
callers: [
{service_account: "beta"},
{service_account: "gamma"}
/* detached comment */
]
Pass 2 — the comment is gone from the output entirely:
callers: [
{service_account: "beta"},
{service_account: "gamma"}
]
Pass 3 and later are identical to pass 2, so the file settles into a stable state with the comment permanently lost.
Expected
format(format(x)) == format(x), and no comment content is ever dropped.
Notes
- The trigger is the blank line between the comment and the element that follows it. Deleting the blank line from the repro makes the file stable after pass 1 with the comment preserved in place.
- Comment style does not matter: a
// detached comment in the same position is deleted the same way.
- The comment drifts one element per pass before being deleted, so a longer list delays the loss rather than avoiding it. With three following entries the deletion lands on pass 3 instead of pass 2, which makes it easy to miss — a single format run looks merely like an odd reflow, and the content disappears on a later, unrelated run.
- Practical impact:
buf format -w in a pre-commit hook. The first commit that touches such a file moves the comment, a later commit deletes it, and neither diff is obviously a content loss to a reviewer.
- 1.70.0 does not lose the comment. It joins it onto the following element instead (
/* detached comment */ {service_account: "gamma"}), which is stable under repeated formatting.
buf version: 1.72.0 (also reproduces on 1.71.0; 1.70.0 is unaffected). Built from source at tag
v1.72.0, linux/amd64, Go 1.26.4.Command:
buf format repro.proto, applied repeatedly to its own output.Repro file (
repro.proto)Observed
Each pass produces different output, and the comment is destroyed on the second pass.
Pass 1 — the comment moves past the element it annotated, down to the closing bracket, and is dedented to the bracket's level:
callers: [ {service_account: "beta"}, {service_account: "gamma"} /* detached comment */ ]Pass 2 — the comment is gone from the output entirely:
callers: [ {service_account: "beta"}, {service_account: "gamma"} ]Pass 3 and later are identical to pass 2, so the file settles into a stable state with the comment permanently lost.
Expected
format(format(x)) == format(x), and no comment content is ever dropped.Notes
// detached commentin the same position is deleted the same way.buf format -win a pre-commit hook. The first commit that touches such a file moves the comment, a later commit deletes it, and neither diff is obviously a content loss to a reviewer./* detached comment */ {service_account: "gamma"}), which is stable under repeated formatting.