Skip to content

Commit 6ec4489

Browse files
authored
CAS diff command (#849)
Utility to help diff changes between 2 manifests in CAS directories.
1 parent 2eb1e71 commit 6ec4489

File tree

23 files changed

+752
-0
lines changed

23 files changed

+752
-0
lines changed

cmd/casdiff/casdiff.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright 2021-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"io/fs"
22+
"strconv"
23+
24+
"github.com/bufbuild/buf/private/bufpkg/bufcas"
25+
"github.com/bufbuild/buf/private/pkg/app/appcmd"
26+
"github.com/bufbuild/buf/private/pkg/app/appext"
27+
"github.com/bufbuild/buf/private/pkg/slicesext"
28+
"github.com/bufbuild/buf/private/pkg/slogapp"
29+
"github.com/bufbuild/buf/private/pkg/storage"
30+
"github.com/bufbuild/buf/private/pkg/storage/storageos"
31+
"github.com/bufbuild/modules/private/bufpkg/bufstate"
32+
"github.com/spf13/pflag"
33+
)
34+
35+
// format is a format to print the casdiff.
36+
type format int
37+
38+
const (
39+
formatFlagName = "format"
40+
formatFlagShortName = "f"
41+
)
42+
43+
const (
44+
formatText format = iota + 1
45+
formatMarkdown
46+
)
47+
48+
//nolint:gochecknoglobals // treated as consts
49+
var (
50+
formatsValuesToNames = map[format]string{
51+
formatText: "text",
52+
formatMarkdown: "markdown",
53+
}
54+
formatsNamesToValues, _ = slicesext.ToUniqueValuesMap(
55+
slicesext.MapKeysToSlice(formatsValuesToNames),
56+
func(f format) string { return formatsValuesToNames[f] },
57+
)
58+
allFormatsString = slicesext.MapKeysToSortedSlice(formatsNamesToValues)
59+
)
60+
61+
func (f format) String() string {
62+
if n, ok := formatsValuesToNames[f]; ok {
63+
return n
64+
}
65+
return strconv.Itoa(int(f))
66+
}
67+
68+
func newCommand(name string) *appcmd.Command {
69+
builder := appext.NewBuilder(
70+
name,
71+
appext.BuilderWithLoggerProvider(slogapp.LoggerProvider),
72+
)
73+
flags := newFlags()
74+
return &appcmd.Command{
75+
Use: name + " <from> <to>",
76+
Short: "Run a CAS diff.",
77+
Args: appcmd.ExactArgs(2),
78+
BindFlags: flags.bind,
79+
Run: builder.NewRunFunc(
80+
func(ctx context.Context, container appext.Container) error {
81+
return run(ctx, container, flags)
82+
},
83+
),
84+
}
85+
}
86+
87+
type flags struct {
88+
format string
89+
}
90+
91+
func newFlags() *flags {
92+
return &flags{}
93+
}
94+
95+
func (f *flags) bind(flagSet *pflag.FlagSet) {
96+
flagSet.StringVarP(
97+
&f.format,
98+
formatFlagName,
99+
formatFlagShortName,
100+
formatText.String(),
101+
fmt.Sprintf(`The out format to use. Must be one of %s`, allFormatsString),
102+
)
103+
}
104+
105+
func run(
106+
ctx context.Context,
107+
container appext.Container,
108+
flags *flags,
109+
) error {
110+
format, ok := formatsNamesToValues[flags.format]
111+
if !ok {
112+
return fmt.Errorf("unsupported format %s", flags.format)
113+
}
114+
from, to := container.Arg(0), container.Arg(1) //nolint:varnamelen // from/to used symmetrically
115+
if from == to {
116+
return printDiff(newManifestDiff(), format)
117+
}
118+
// first, attempt to match from/to as module references in a state file in the same directory
119+
// where the command is run
120+
bucket, err := storageos.NewProvider().NewReadWriteBucket(".")
121+
if err != nil {
122+
return fmt.Errorf("new rw bucket: %w", err)
123+
}
124+
moduleStateReader, err := bucket.Get(ctx, bufstate.ModStateFileName)
125+
if err != nil {
126+
if !errors.Is(err, fs.ErrNotExist) {
127+
return fmt.Errorf("read module state file: %w", err)
128+
}
129+
// if the state file does not exist, we assume we are in the cas directory, and that from/to are
130+
// the manifest paths
131+
mdiff, err := calculateDiffFromCASDirectory(ctx, bucket, from, to)
132+
if err != nil {
133+
return fmt.Errorf("calculate cas diff: %w", err)
134+
}
135+
return printDiff(mdiff, format)
136+
}
137+
// state file was found, attempt to parse it and match from/to with its references
138+
stateRW, err := bufstate.NewReadWriter()
139+
if err != nil {
140+
return fmt.Errorf("new state rw: %w", err)
141+
}
142+
moduleState, err := stateRW.ReadModStateFile(moduleStateReader)
143+
if err != nil {
144+
return fmt.Errorf("read module state: %w", err)
145+
}
146+
var (
147+
fromManifestPath string
148+
toManifestPath string
149+
)
150+
for _, ref := range moduleState.GetReferences() {
151+
if ref.GetName() == from {
152+
fromManifestPath = ref.GetDigest()
153+
if toManifestPath != "" {
154+
break
155+
}
156+
} else if ref.GetName() == to {
157+
toManifestPath = ref.GetDigest()
158+
if fromManifestPath != "" {
159+
break
160+
}
161+
}
162+
}
163+
if fromManifestPath == "" {
164+
return fmt.Errorf("from reference %s not found in the module state file", from)
165+
}
166+
if toManifestPath == "" {
167+
return fmt.Errorf("to reference %s not found in the module state file", to)
168+
}
169+
if fromManifestPath == toManifestPath {
170+
return printDiff(newManifestDiff(), format)
171+
}
172+
casBucket, err := storageos.NewProvider().NewReadWriteBucket("cas")
173+
if err != nil {
174+
return fmt.Errorf("new rw cas bucket: %w", err)
175+
}
176+
mdiff, err := calculateDiffFromCASDirectory(ctx, casBucket, fromManifestPath, toManifestPath)
177+
if err != nil {
178+
return fmt.Errorf("calculate cas diff from state references: %w", err)
179+
}
180+
return printDiff(mdiff, format)
181+
}
182+
183+
// calculateDiffFromCASDirectory takes the cas bucket, and the from/to manifest paths to calculate a
184+
// diff.
185+
func calculateDiffFromCASDirectory(
186+
ctx context.Context,
187+
casBucket storage.ReadBucket,
188+
fromManifestPath string,
189+
toManifestPath string,
190+
) (*manifestDiff, error) {
191+
if fromManifestPath == toManifestPath {
192+
return newManifestDiff(), nil
193+
}
194+
fromManifest, err := readManifest(ctx, casBucket, fromManifestPath)
195+
if err != nil {
196+
return nil, fmt.Errorf("read manifest from: %w", err)
197+
}
198+
toManifest, err := readManifest(ctx, casBucket, toManifestPath)
199+
if err != nil {
200+
return nil, fmt.Errorf("read manifest to: %w", err)
201+
}
202+
return buildManifestDiff(ctx, fromManifest, toManifest, casBucket)
203+
}
204+
205+
func readManifest(ctx context.Context, bucket storage.ReadBucket, manifestPath string) (bufcas.Manifest, error) {
206+
data, err := storage.ReadPath(ctx, bucket, manifestPath)
207+
if err != nil {
208+
return nil, fmt.Errorf("read path: %w", err)
209+
}
210+
m, err := bufcas.ParseManifest(string(data))
211+
if err != nil {
212+
return nil, fmt.Errorf("parse manifest: %w", err)
213+
}
214+
return m, nil
215+
}
216+
217+
func printDiff(mdiff *manifestDiff, format format) error {
218+
switch format {
219+
case formatText:
220+
mdiff.printText()
221+
case formatMarkdown:
222+
mdiff.printMarkdown()
223+
default:
224+
return fmt.Errorf("format %s not supported", format.String())
225+
}
226+
return nil
227+
}

cmd/casdiff/main.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2021-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
20+
"github.com/bufbuild/buf/private/pkg/app/appcmd"
21+
)
22+
23+
const (
24+
rootCmdName = "casdiff"
25+
)
26+
27+
func main() {
28+
appcmd.Main(context.Background(), newCommand(rootCmdName))
29+
}

0 commit comments

Comments
 (0)