diff --git a/internal/gitrepo/gitrepo.go b/internal/gitrepo/gitrepo.go index 8f12fcb..f7e15ce 100644 --- a/internal/gitrepo/gitrepo.go +++ b/internal/gitrepo/gitrepo.go @@ -48,7 +48,7 @@ type Catter interface { Cat(ctx context.Context, dst io.Writer, tp object.Type, id githash.SHA1) error } -// Cat copies the content of the given object from the cache into dst. +// Cat copies the content of the given object from the repository into dst. // If the type of the object requested does not match the requested type // and it can be trivially dereferenced to the requested type // (e.g. a commit is found during a request for a tree), @@ -58,7 +58,7 @@ type Catter interface { // it's assumed that reading from buf will read the bytes // previously written to buf. // -// If cat implements [TypeCatter], it is used instead of reading individual objects. +// If cat implements [Catter], it is used instead of reading individual objects. // buf will not be used in this case. func Cat(ctx context.Context, repo Repository, dst io.Writer, wantType object.Type, id githash.SHA1) error { if typeCat, ok := repo.(Catter); ok { @@ -119,7 +119,6 @@ func Cat(ctx context.Context, repo Repository, dst io.Writer, wantType object.Ty return fmt.Errorf("cat %v %v: %v is a %v", wantType, id, nextID, got.Type) } } - } // Map is an in-memory implementation of [Repository]. @@ -144,6 +143,15 @@ func (m Map) Stat(ctx context.Context, id githash.SHA1) (object.Prefix, error) { return obj.Prefix(), nil } +// Cat copies the content of the given object from the map into dst. +// If the type of the object requested does not match the requested type +// and it can be trivially dereferenced to the requested type +// (e.g. a commit is found during a request for a tree), +// then the referenced object is written to dst. +func (m Map) Cat(ctx context.Context, dst io.Writer, tp object.Type, id githash.SHA1) error { + return Cat(ctx, onlyRepository{m}, dst, tp, id) +} + func (m Map) get(ctx context.Context, id githash.SHA1) (Object, error) { obj, ok := m[id] if !ok { @@ -213,3 +221,8 @@ func (obj Object) SHA1() githash.SHA1 { h.Sum(id[:0]) return id } + +// onlyRepository is a small wrapper type that hides any non-Repository methods. +type onlyRepository struct { + Repository +} diff --git a/internal/gitrepo/gitrepo_test.go b/internal/gitrepo/gitrepo_test.go index c379532..564bb6a 100644 --- a/internal/gitrepo/gitrepo_test.go +++ b/internal/gitrepo/gitrepo_test.go @@ -29,7 +29,10 @@ import ( "github.com/google/go-cmp/cmp" ) -var _ Repository = Map(nil) +var _ interface { + Repository + Catter +} = Map(nil) func TestMap(t *testing.T) { ctx := context.Background() diff --git a/internal/gitrepo/notes.go b/internal/gitrepo/notes.go new file mode 100644 index 0000000..eab3c8d --- /dev/null +++ b/internal/gitrepo/notes.go @@ -0,0 +1,74 @@ +// Copyright 2023 The gg Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package gitrepo + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + + "gg-scm.io/pkg/git/githash" + "gg-scm.io/pkg/git/object" +) + +// NotesForCommit reads the notes for a particular commit and root reference. +// If there are no notes for the given ID, +// nothing will be written to dst and NotesForCommit will return nil. +func NotesForCommit(ctx context.Context, cat Catter, dst io.Writer, notesRef githash.SHA1, id githash.SHA1) (err error) { + defer func() { + if err != nil { + err = fmt.Errorf("read notes for %v: %v", id, err) + } + }() + + buf := new(bytes.Buffer) + var prefix strings.Builder + rest := id.String() + for curr := notesRef; ; { + if err := cat.Cat(ctx, buf, object.TypeTree, curr); err != nil { + return err + } + currTree, err := object.ParseTree(buf.Bytes()) + if err != nil { + return err + } + buf.Reset() + if ent := currTree.Search(rest); ent != nil { + // Notes file found. + if !ent.Mode.IsRegular() { + return fmt.Errorf("%s%s: not a regular file", prefix.String(), rest) + } + // TODO(maybe): Prevent tags to blobs? + return cat.Cat(ctx, dst, object.TypeBlob, ent.ObjectID) + } + + dir := rest[:2] + rest = rest[2:] + ent := currTree.Search(dir) + if ent == nil { + return nil + } + if ent.Mode != object.ModeDir { + return fmt.Errorf("%s%s: not a directory", prefix.String(), dir) + } + prefix.WriteString(dir) + prefix.WriteString("/") + curr = ent.ObjectID + } +} diff --git a/internal/gitrepo/notes_test.go b/internal/gitrepo/notes_test.go new file mode 100644 index 0000000..e6a3b72 --- /dev/null +++ b/internal/gitrepo/notes_test.go @@ -0,0 +1,121 @@ +// Copyright 2023 The gg Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package gitrepo + +import ( + "bytes" + "context" + "testing" + + "gg-scm.io/pkg/git/githash" + "gg-scm.io/pkg/git/object" +) + +func TestNotesForCommit(t *testing.T) { + commit1, err := githash.ParseSHA1("85be80f2ff7482f53f6581be8683036d7a59c0e7") + if err != nil { + t.Fatal(err) + } + commit2, err := githash.ParseSHA1("af3ab0b8ddca5513f86812d820802202514ed986") + if err != nil { + t.Fatal(err) + } + commit3, err := githash.ParseSHA1("3134253d1adfa21be31624f6d48243e18651db68") + if err != nil { + t.Fatal(err) + } + + repo := make(Map) + commit2Notes := []byte("Commit #2\n") + commit2NotesBlob := repo.Add(Object{ + Type: object.TypeBlob, + Data: commit2Notes, + }) + subtreeData, err := (object.Tree{ + { + Name: "3ab0b8ddca5513f86812d820802202514ed986", + Mode: object.ModePlain, + ObjectID: commit2NotesBlob, + }, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + subtreeID := repo.Add(Object{ + Type: object.TypeTree, + Data: subtreeData, + }) + + commit1Notes := []byte("Hello, World!\n") + commit1NotesBlob := repo.Add(Object{ + Type: object.TypeBlob, + Data: commit1Notes, + }) + rootData, err := (object.Tree{ + { + Name: "85be80f2ff7482f53f6581be8683036d7a59c0e7", + Mode: object.ModePlain, + ObjectID: commit1NotesBlob, + }, + { + Name: "af", + Mode: object.ModeDir, + ObjectID: subtreeID, + }, + }).MarshalBinary() + if err != nil { + t.Fatal(err) + } + root := repo.Add(Object{ + Type: object.TypeTree, + Data: rootData, + }) + + tests := []struct { + name string + commit githash.SHA1 + want []byte + }{ + { + name: "Root", + commit: commit1, + want: commit1Notes, + }, + { + name: "Subdir", + commit: commit2, + want: commit2Notes, + }, + { + name: "NotFound", + commit: commit3, + want: nil, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + got := new(bytes.Buffer) + if err := NotesForCommit(ctx, repo, got, root, test.commit); err != nil { + t.Error(err) + } + if !bytes.Equal(got.Bytes(), test.want) { + t.Errorf("notes = %q; want %q", got, test.want) + } + }) + } +}