Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 6457c36

Browse files
committedJan 3, 2016
refactor object patch command to work more betterer
License: MIT Signed-off-by: Jeromy <jeromyj@gmail.com>
1 parent 96698d3 commit 6457c36

File tree

4 files changed

+330
-254
lines changed

4 files changed

+330
-254
lines changed
 

‎core/commands/object.go ‎core/commands/object/object.go

+14-247
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package commands
1+
package objectcmd
22

33
import (
44
"bytes"
@@ -13,14 +13,11 @@ import (
1313

1414
mh "github.com/ipfs/go-ipfs/Godeps/_workspace/src/github.com/jbenet/go-multihash"
1515

16-
key "github.com/ipfs/go-ipfs/blocks/key"
1716
cmds "github.com/ipfs/go-ipfs/commands"
1817
core "github.com/ipfs/go-ipfs/core"
1918
dag "github.com/ipfs/go-ipfs/merkledag"
20-
dagutils "github.com/ipfs/go-ipfs/merkledag/utils"
2119
path "github.com/ipfs/go-ipfs/path"
2220
ft "github.com/ipfs/go-ipfs/unixfs"
23-
u "github.com/ipfs/go-ipfs/util"
2421
)
2522

2623
// ErrObjectTooLarge is returned when too much data was read from stdin. current limit 512k
@@ -61,17 +58,17 @@ ipfs object patch <args> - Create new object from old ones
6158
},
6259

6360
Subcommands: map[string]*cmds.Command{
64-
"data": objectDataCmd,
65-
"links": objectLinksCmd,
66-
"get": objectGetCmd,
67-
"put": objectPutCmd,
68-
"stat": objectStatCmd,
69-
"new": objectNewCmd,
70-
"patch": objectPatchCmd,
61+
"data": ObjectDataCmd,
62+
"links": ObjectLinksCmd,
63+
"get": ObjectGetCmd,
64+
"put": ObjectPutCmd,
65+
"stat": ObjectStatCmd,
66+
"new": ObjectNewCmd,
67+
"patch": ObjectPatchCmd,
7168
},
7269
}
7370

74-
var objectDataCmd = &cmds.Command{
71+
var ObjectDataCmd = &cmds.Command{
7572
Helptext: cmds.HelpText{
7673
Tagline: "Outputs the raw bytes in an IPFS object",
7774
ShortDescription: `
@@ -109,7 +106,7 @@ output is the raw data of the object.
109106
},
110107
}
111108

112-
var objectLinksCmd = &cmds.Command{
109+
var ObjectLinksCmd = &cmds.Command{
113110
Helptext: cmds.HelpText{
114111
Tagline: "Outputs the links pointed to by the specified object",
115112
ShortDescription: `
@@ -158,7 +155,7 @@ multihash.
158155
Type: Object{},
159156
}
160157

161-
var objectGetCmd = &cmds.Command{
158+
var ObjectGetCmd = &cmds.Command{
162159
Helptext: cmds.HelpText{
163160
Tagline: "Get and serialize the DAG node named by <key>",
164161
ShortDescription: `
@@ -229,7 +226,7 @@ This command outputs data in the following encodings:
229226
},
230227
}
231228

232-
var objectStatCmd = &cmds.Command{
229+
var ObjectStatCmd = &cmds.Command{
233230
Helptext: cmds.HelpText{
234231
Tagline: "Get stats for the DAG node named by <key>",
235232
ShortDescription: `
@@ -290,7 +287,7 @@ var objectStatCmd = &cmds.Command{
290287
},
291288
}
292289

293-
var objectPutCmd = &cmds.Command{
290+
var ObjectPutCmd = &cmds.Command{
294291
Helptext: cmds.HelpText{
295292
Tagline: "Stores input as a DAG object, outputs its key",
296293
ShortDescription: `
@@ -377,7 +374,7 @@ and then run
377374
Type: Object{},
378375
}
379376

380-
var objectNewCmd = &cmds.Command{
377+
var ObjectNewCmd = &cmds.Command{
381378
Helptext: cmds.HelpText{
382379
Tagline: "creates a new object from an ipfs template",
383380
ShortDescription: `
@@ -430,235 +427,6 @@ Available templates:
430427
Type: Object{},
431428
}
432429

433-
var objectPatchCmd = &cmds.Command{
434-
Helptext: cmds.HelpText{
435-
Tagline: "Create a new merkledag object based on an existing one",
436-
ShortDescription: `
437-
'ipfs object patch <root> <cmd> <args>' is a plumbing command used to
438-
build custom DAG objects. It adds and removes links from objects, creating a new
439-
object as a result. This is the merkle-dag version of modifying an object. It
440-
can also set the data inside a node with 'set-data' and append to that data as
441-
well with 'append-data'.
442-
443-
Patch commands:
444-
add-link <name> <ref> - adds a link to a node
445-
rm-link <name> - removes a link from a node
446-
set-data - sets a nodes data from stdin
447-
append-data - appends to a nodes data from stdin
448-
449-
Examples:
450-
451-
EMPTY_DIR=$(ipfs object new unixfs-dir)
452-
BAR=$(echo "bar" | ipfs add -q)
453-
ipfs object patch $EMPTY_DIR add-link foo $BAR
454-
455-
This takes an empty directory, and adds a link named foo under it, pointing to
456-
a file containing 'bar', and returns the hash of the new object.
457-
458-
ipfs object patch $FOO_BAR rm-link foo
459-
460-
This removes the link named foo from the hash in $FOO_BAR and returns the
461-
resulting object hash.
462-
463-
The data inside the node can be modified as well:
464-
465-
ipfs object patch $FOO_BAR set-data < file.dat
466-
ipfs object patch $FOO_BAR append-data < file.dat
467-
468-
`,
469-
},
470-
Options: []cmds.Option{
471-
cmds.BoolOption("create", "p", "create intermediate directories on add-link"),
472-
},
473-
Arguments: []cmds.Argument{
474-
cmds.StringArg("root", true, false, "the hash of the node to modify"),
475-
cmds.StringArg("command", true, false, "the operation to perform"),
476-
cmds.StringArg("args", true, true, "extra arguments").EnableStdin(),
477-
},
478-
Type: Object{},
479-
Run: func(req cmds.Request, res cmds.Response) {
480-
nd, err := req.InvocContext().GetNode()
481-
if err != nil {
482-
res.SetError(err, cmds.ErrNormal)
483-
return
484-
}
485-
486-
rootarg := req.Arguments()[0]
487-
if strings.HasPrefix(rootarg, "/ipfs/") {
488-
rootarg = rootarg[6:]
489-
}
490-
rhash := key.B58KeyDecode(rootarg)
491-
if rhash == "" {
492-
res.SetError(fmt.Errorf("incorrectly formatted root hash: %s", req.Arguments()[0]), cmds.ErrNormal)
493-
return
494-
}
495-
496-
rnode, err := nd.DAG.Get(req.Context(), rhash)
497-
if err != nil {
498-
res.SetError(err, cmds.ErrNormal)
499-
return
500-
}
501-
502-
action := req.Arguments()[1]
503-
504-
switch action {
505-
case "add-link":
506-
k, err := addLinkCaller(req, rnode)
507-
if err != nil {
508-
res.SetError(err, cmds.ErrNormal)
509-
return
510-
}
511-
res.SetOutput(&Object{Hash: k.B58String()})
512-
case "rm-link":
513-
k, err := rmLinkCaller(req, rnode)
514-
if err != nil {
515-
res.SetError(err, cmds.ErrNormal)
516-
return
517-
}
518-
res.SetOutput(&Object{Hash: k.B58String()})
519-
case "set-data":
520-
k, err := setDataCaller(req, rnode)
521-
if err != nil {
522-
res.SetError(err, cmds.ErrNormal)
523-
return
524-
}
525-
res.SetOutput(&Object{Hash: k.B58String()})
526-
case "append-data":
527-
k, err := appendDataCaller(req, rnode)
528-
if err != nil {
529-
res.SetError(err, cmds.ErrNormal)
530-
return
531-
}
532-
res.SetOutput(&Object{Hash: k.B58String()})
533-
default:
534-
res.SetError(fmt.Errorf("unrecognized subcommand"), cmds.ErrNormal)
535-
return
536-
}
537-
},
538-
Marshalers: cmds.MarshalerMap{
539-
cmds.Text: func(res cmds.Response) (io.Reader, error) {
540-
o, ok := res.Output().(*Object)
541-
if !ok {
542-
return nil, u.ErrCast()
543-
}
544-
545-
return strings.NewReader(o.Hash + "\n"), nil
546-
},
547-
},
548-
}
549-
550-
func appendDataCaller(req cmds.Request, root *dag.Node) (key.Key, error) {
551-
if len(req.Arguments()) < 3 {
552-
return "", fmt.Errorf("not enough arguments for set-data")
553-
}
554-
555-
nd, err := req.InvocContext().GetNode()
556-
if err != nil {
557-
return "", err
558-
}
559-
560-
root.Data = append(root.Data, []byte(req.Arguments()[2])...)
561-
562-
newkey, err := nd.DAG.Add(root)
563-
if err != nil {
564-
return "", err
565-
}
566-
567-
return newkey, nil
568-
}
569-
570-
func setDataCaller(req cmds.Request, root *dag.Node) (key.Key, error) {
571-
if len(req.Arguments()) < 3 {
572-
return "", fmt.Errorf("not enough arguments for set-data")
573-
}
574-
575-
nd, err := req.InvocContext().GetNode()
576-
if err != nil {
577-
return "", err
578-
}
579-
580-
root.Data = []byte(req.Arguments()[2])
581-
582-
newkey, err := nd.DAG.Add(root)
583-
if err != nil {
584-
return "", err
585-
}
586-
587-
return newkey, nil
588-
}
589-
590-
func rmLinkCaller(req cmds.Request, root *dag.Node) (key.Key, error) {
591-
if len(req.Arguments()) < 3 {
592-
return "", fmt.Errorf("not enough arguments for rm-link")
593-
}
594-
595-
nd, err := req.InvocContext().GetNode()
596-
if err != nil {
597-
return "", err
598-
}
599-
600-
path := req.Arguments()[2]
601-
602-
e := dagutils.NewDagEditor(root, nd.DAG)
603-
604-
err = e.RmLink(req.Context(), path)
605-
if err != nil {
606-
return "", err
607-
}
608-
609-
nnode, err := e.Finalize(nd.DAG)
610-
if err != nil {
611-
return "", err
612-
}
613-
614-
return nnode.Key()
615-
}
616-
617-
func addLinkCaller(req cmds.Request, root *dag.Node) (key.Key, error) {
618-
if len(req.Arguments()) < 4 {
619-
return "", fmt.Errorf("not enough arguments for add-link")
620-
}
621-
622-
nd, err := req.InvocContext().GetNode()
623-
if err != nil {
624-
return "", err
625-
}
626-
627-
path := req.Arguments()[2]
628-
childk := key.B58KeyDecode(req.Arguments()[3])
629-
630-
create, _, err := req.Option("create").Bool()
631-
if err != nil {
632-
return "", err
633-
}
634-
635-
var createfunc func() *dag.Node
636-
if create {
637-
createfunc = func() *dag.Node {
638-
return &dag.Node{Data: ft.FolderPBData()}
639-
}
640-
}
641-
642-
e := dagutils.NewDagEditor(root, nd.DAG)
643-
644-
childnd, err := nd.DAG.Get(req.Context(), childk)
645-
if err != nil {
646-
return "", err
647-
}
648-
649-
err = e.InsertNodeAtPath(req.Context(), path, childnd, createfunc)
650-
if err != nil {
651-
return "", err
652-
}
653-
654-
nnode, err := e.Finalize(nd.DAG)
655-
if err != nil {
656-
return "", err
657-
}
658-
659-
return nnode.Key()
660-
}
661-
662430
func nodeFromTemplate(template string) (*dag.Node, error) {
663431
switch template {
664432
case "unixfs-dir":
@@ -757,7 +525,6 @@ func getObjectEnc(o interface{}) objectEncoding {
757525
v, ok := o.(string)
758526
if !ok {
759527
// chosen as default because it's human readable
760-
log.Warning("option is not a string - falling back to json")
761528
return objectEncodingJSON
762529
}
763530

‎core/commands/object/patch.go

+308
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
package objectcmd
2+
3+
import (
4+
"io"
5+
"io/ioutil"
6+
"strings"
7+
8+
key "github.com/ipfs/go-ipfs/blocks/key"
9+
cmds "github.com/ipfs/go-ipfs/commands"
10+
core "github.com/ipfs/go-ipfs/core"
11+
dag "github.com/ipfs/go-ipfs/merkledag"
12+
dagutils "github.com/ipfs/go-ipfs/merkledag/utils"
13+
path "github.com/ipfs/go-ipfs/path"
14+
ft "github.com/ipfs/go-ipfs/unixfs"
15+
u "github.com/ipfs/go-ipfs/util"
16+
)
17+
18+
var ObjectPatchCmd = &cmds.Command{
19+
Helptext: cmds.HelpText{
20+
Tagline: "Create a new merkledag object based on an existing one",
21+
ShortDescription: `
22+
'ipfs object patch <root> <cmd> <args>' is a plumbing command used to
23+
build custom DAG objects. It adds and removes links from objects, creating a new
24+
object as a result. This is the merkle-dag version of modifying an object. It
25+
can also set the data inside a node with 'set-data' and append to that data as
26+
well with 'append-data'.
27+
28+
Patch commands:
29+
add-link <name> <ref> - adds a link to a node
30+
rm-link <name> - removes a link from a node
31+
set-data - sets a nodes data from stdin
32+
append-data - appends to a nodes data from stdin
33+
34+
35+
36+
ipfs object patch $FOO_BAR rm-link foo
37+
38+
This removes the link named foo from the hash in $FOO_BAR and returns the
39+
resulting object hash.
40+
41+
The data inside the node can be modified as well:
42+
43+
ipfs object patch $FOO_BAR set-data < file.dat
44+
ipfs object patch $FOO_BAR append-data < file.dat
45+
46+
`,
47+
},
48+
Arguments: []cmds.Argument{},
49+
Subcommands: map[string]*cmds.Command{
50+
"append-data": patchAppendDataCmd,
51+
"add-link": patchAddLinkCmd,
52+
"rm-link": patchRmLinkCmd,
53+
"set-data": patchSetDataCmd,
54+
},
55+
}
56+
57+
func objectMarshaler(res cmds.Response) (io.Reader, error) {
58+
o, ok := res.Output().(*Object)
59+
if !ok {
60+
return nil, u.ErrCast()
61+
}
62+
63+
return strings.NewReader(o.Hash + "\n"), nil
64+
}
65+
66+
var patchAppendDataCmd = &cmds.Command{
67+
Helptext: cmds.HelpText{
68+
Tagline: "Append data to the data segment of a dag node",
69+
ShortDescription: `
70+
`,
71+
},
72+
Arguments: []cmds.Argument{
73+
cmds.StringArg("root", true, false, "the hash of the node to modify"),
74+
cmds.FileArg("data", true, false, "data to append").EnableStdin(),
75+
},
76+
Run: func(req cmds.Request, res cmds.Response) {
77+
nd, err := req.InvocContext().GetNode()
78+
if err != nil {
79+
res.SetError(err, cmds.ErrNormal)
80+
return
81+
}
82+
83+
root, err := path.ParsePath(req.Arguments()[0])
84+
if err != nil {
85+
res.SetError(err, cmds.ErrNormal)
86+
return
87+
}
88+
89+
rootnd, err := core.Resolve(req.Context(), nd, root)
90+
if err != nil {
91+
res.SetError(err, cmds.ErrNormal)
92+
return
93+
}
94+
95+
data, err := ioutil.ReadAll(req.Files())
96+
if err != nil {
97+
res.SetError(err, cmds.ErrNormal)
98+
return
99+
}
100+
101+
rootnd.Data = append(rootnd.Data, data...)
102+
103+
newkey, err := nd.DAG.Add(rootnd)
104+
if err != nil {
105+
res.SetError(err, cmds.ErrNormal)
106+
return
107+
}
108+
109+
res.SetOutput(&Object{Hash: newkey.B58String()})
110+
},
111+
Type: Object{},
112+
Marshalers: cmds.MarshalerMap{
113+
cmds.Text: objectMarshaler,
114+
},
115+
}
116+
117+
var patchSetDataCmd = &cmds.Command{
118+
Helptext: cmds.HelpText{},
119+
Arguments: []cmds.Argument{
120+
cmds.StringArg("root", true, false, "the hash of the node to modify"),
121+
cmds.FileArg("data", true, false, "data fill with").EnableStdin(),
122+
},
123+
Run: func(req cmds.Request, res cmds.Response) {
124+
nd, err := req.InvocContext().GetNode()
125+
if err != nil {
126+
res.SetError(err, cmds.ErrNormal)
127+
return
128+
}
129+
130+
rp, err := path.ParsePath(req.Arguments()[0])
131+
if err != nil {
132+
res.SetError(err, cmds.ErrNormal)
133+
return
134+
}
135+
136+
root, err := core.Resolve(req.Context(), nd, rp)
137+
if err != nil {
138+
res.SetError(err, cmds.ErrNormal)
139+
return
140+
}
141+
142+
data, err := ioutil.ReadAll(req.Files())
143+
if err != nil {
144+
res.SetError(err, cmds.ErrNormal)
145+
return
146+
}
147+
148+
root.Data = data
149+
150+
newkey, err := nd.DAG.Add(root)
151+
if err != nil {
152+
res.SetError(err, cmds.ErrNormal)
153+
return
154+
}
155+
156+
res.SetOutput(&Object{Hash: newkey.B58String()})
157+
},
158+
Type: Object{},
159+
Marshalers: cmds.MarshalerMap{
160+
cmds.Text: objectMarshaler,
161+
},
162+
}
163+
164+
var patchRmLinkCmd = &cmds.Command{
165+
Helptext: cmds.HelpText{},
166+
Arguments: []cmds.Argument{
167+
cmds.StringArg("root", true, false, "the hash of the node to modify"),
168+
cmds.StringArg("link", true, false, "name of the link to remove"),
169+
},
170+
Run: func(req cmds.Request, res cmds.Response) {
171+
nd, err := req.InvocContext().GetNode()
172+
if err != nil {
173+
res.SetError(err, cmds.ErrNormal)
174+
return
175+
}
176+
177+
rootp, err := path.ParsePath(req.Arguments()[0])
178+
if err != nil {
179+
res.SetError(err, cmds.ErrNormal)
180+
return
181+
}
182+
183+
root, err := core.Resolve(req.Context(), nd, rootp)
184+
if err != nil {
185+
res.SetError(err, cmds.ErrNormal)
186+
return
187+
}
188+
189+
path := req.Arguments()[1]
190+
191+
e := dagutils.NewDagEditor(root, nd.DAG)
192+
193+
err = e.RmLink(req.Context(), path)
194+
if err != nil {
195+
res.SetError(err, cmds.ErrNormal)
196+
return
197+
}
198+
199+
nnode, err := e.Finalize(nd.DAG)
200+
if err != nil {
201+
res.SetError(err, cmds.ErrNormal)
202+
return
203+
}
204+
205+
nk, err := nnode.Key()
206+
if err != nil {
207+
res.SetError(err, cmds.ErrNormal)
208+
return
209+
}
210+
211+
res.SetOutput(&Object{Hash: nk.B58String()})
212+
},
213+
Type: Object{},
214+
Marshalers: cmds.MarshalerMap{
215+
cmds.Text: objectMarshaler,
216+
},
217+
}
218+
219+
var patchAddLinkCmd = &cmds.Command{
220+
Helptext: cmds.HelpText{
221+
Tagline: "add a link to a given object",
222+
ShortDescription: `
223+
Examples:
224+
225+
EMPTY_DIR=$(ipfs object new unixfs-dir)
226+
BAR=$(echo "bar" | ipfs add -q)
227+
ipfs object patch $EMPTY_DIR add-link foo $BAR
228+
229+
This takes an empty directory, and adds a link named foo under it, pointing to
230+
a file containing 'bar', and returns the hash of the new object.
231+
`,
232+
},
233+
Options: []cmds.Option{
234+
cmds.BoolOption("p", "create", "create intermediary nodes"),
235+
},
236+
Arguments: []cmds.Argument{
237+
cmds.StringArg("root", true, false, "the hash of the node to modify"),
238+
cmds.StringArg("name", true, false, "name of link to create"),
239+
cmds.StringArg("ref", true, false, "ipfs object to add link to"),
240+
},
241+
Run: func(req cmds.Request, res cmds.Response) {
242+
nd, err := req.InvocContext().GetNode()
243+
if err != nil {
244+
res.SetError(err, cmds.ErrNormal)
245+
return
246+
}
247+
248+
rootp, err := path.ParsePath(req.Arguments()[0])
249+
if err != nil {
250+
res.SetError(err, cmds.ErrNormal)
251+
return
252+
}
253+
254+
root, err := core.Resolve(req.Context(), nd, rootp)
255+
if err != nil {
256+
res.SetError(err, cmds.ErrNormal)
257+
return
258+
}
259+
260+
path := req.Arguments()[1]
261+
childk := key.B58KeyDecode(req.Arguments()[2])
262+
263+
create, _, err := req.Option("create").Bool()
264+
if err != nil {
265+
res.SetError(err, cmds.ErrNormal)
266+
return
267+
}
268+
269+
var createfunc func() *dag.Node
270+
if create {
271+
createfunc = func() *dag.Node {
272+
return &dag.Node{Data: ft.FolderPBData()}
273+
}
274+
}
275+
276+
e := dagutils.NewDagEditor(root, nd.DAG)
277+
278+
childnd, err := nd.DAG.Get(req.Context(), childk)
279+
if err != nil {
280+
res.SetError(err, cmds.ErrNormal)
281+
return
282+
}
283+
284+
err = e.InsertNodeAtPath(req.Context(), path, childnd, createfunc)
285+
if err != nil {
286+
res.SetError(err, cmds.ErrNormal)
287+
return
288+
}
289+
290+
nnode, err := e.Finalize(nd.DAG)
291+
if err != nil {
292+
res.SetError(err, cmds.ErrNormal)
293+
return
294+
}
295+
296+
nk, err := nnode.Key()
297+
if err != nil {
298+
res.SetError(err, cmds.ErrNormal)
299+
return
300+
}
301+
302+
res.SetOutput(&Object{Hash: nk.B58String()})
303+
},
304+
Type: Object{},
305+
Marshalers: cmds.MarshalerMap{
306+
cmds.Text: objectMarshaler,
307+
},
308+
}

‎core/commands/root.go

+7-6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
cmds "github.com/ipfs/go-ipfs/commands"
88
files "github.com/ipfs/go-ipfs/core/commands/files"
9+
ocmd "github.com/ipfs/go-ipfs/core/commands/object"
910
unixfs "github.com/ipfs/go-ipfs/core/commands/unixfs"
1011
logging "github.com/ipfs/go-ipfs/vendor/QmQg1J6vikuXF9oDvm4wpdeAUvvkVEKW1EYDw9HhTMnP2b/go-log"
1112
)
@@ -107,7 +108,7 @@ var rootSubcommands = map[string]*cmds.Command{
107108
"ls": LsCmd,
108109
"mount": MountCmd,
109110
"name": NameCmd,
110-
"object": ObjectCmd,
111+
"object": ocmd.ObjectCmd,
111112
"pin": PinCmd,
112113
"ping": PingCmd,
113114
"refs": RefsCmd,
@@ -148,11 +149,11 @@ var rootROSubcommands = map[string]*cmds.Command{
148149
},
149150
"object": &cmds.Command{
150151
Subcommands: map[string]*cmds.Command{
151-
"data": objectDataCmd,
152-
"links": objectLinksCmd,
153-
"get": objectGetCmd,
154-
"stat": objectStatCmd,
155-
"patch": objectPatchCmd,
152+
"data": ocmd.ObjectDataCmd,
153+
"links": ocmd.ObjectLinksCmd,
154+
"get": ocmd.ObjectGetCmd,
155+
"stat": ocmd.ObjectStatCmd,
156+
"patch": ocmd.ObjectPatchCmd,
156157
},
157158
},
158159
"refs": RefsROCmd,

‎test/sharness/t0051-object.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ test_patch_create_path() {
1616
target=$3
1717

1818
test_expect_success "object patch --create works" '
19-
PCOUT=$(ipfs object patch --create $root add-link $name $target)
19+
PCOUT=$(ipfs object patch $root add-link --create $name $target)
2020
'
2121

2222
test_expect_success "output looks good" '

0 commit comments

Comments
 (0)
Please sign in to comment.