From 5ebb6597a86e8a00cee80e23ad930dd0c7c027e3 Mon Sep 17 00:00:00 2001 From: mediocregopher <> Date: Sun, 15 Mar 2020 13:50:24 -0600 Subject: [PATCH] normalize how git commits are interacted with, including changing VerifyComit -> VerifyCommits --- type: change message: |- normalize how git commits are interacted with, including changing VerifyComit -> VerifyCommits This commit attempts to normalize git commit interactions in order to reduce the amount of manual `GitRepo.CommitObject`, `GitRepo.TreeObject`, `Commit.UnmarshalText`, and `Commit.Interface` calls are done, by creating a single structure (`GitCommit`) which holds the output of those calls, and is only created by a single method (`GetGitCommit`), which is then used by a bunch of other methods to expand its functionality, including implementing a range request which can be used by verify and the pre-receive hook (though it's only used by the hook, currently). change_hash: AMae4PL6+jrxhn2KEGHejstcdT37Gw/jjkl/UuovHcgd credentials: - type: pgp_signature pub_key_id: 95C46FA6A41148AC body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl5uhvoACgkQlcRvpqQRSKzJrhAAqi2LEQVTyVktfsOBv/CZmefclLLqWTChVoeIZt2EAGDDGygmrx88hI0SEAviOzPMn0kiZFDeY5k7ICJMhJ9RVDU9WjH7fbOboMJW19rVhx6Ke/M2ERtrT0OFLRmFVJVDM0P8SEheQvR3HE/iiypBICVCtp+meHEq9mOJWZlZnoCqMaulAy/Nnq4N1VD0yPPlr16+yxMqedKHcgKbcH8K61ltNAjXDT+tCWwCq1huA5MVSuTm5EwqIeKPN6JKgwATv8Ku2GhYZWHSGUwecP1J3x2XTDPeChCQVDpC232Pxwk8z/D36F3J/XOfkdl0QYQ077xL1IJfYOnuuHir47CokDf3G0XCQnJ/+X4pZdtP387rc045o/2bhUi2U4eJ5HgS7Hvyi6EApT0Czv7SeJePTvdnRUYse8ZYuIwYXj5GWWxnbKQzLpyjcHdQc2a3B3RN84zXqqAOS6ObFrFPZQIfz2rfQojZN8kvcmUvYhJXSaT65XmqFjyJ4n6grrEnK/N+MfbnpzyF/yvlzxWPqGFQOQj9meosbTAdgZbmdwYqa5r1ee8DmlkzNJJxze96h503a733yciN8Ef4hGZNlRV6YFegkK/cCgKaA4NCEALKb1t0Uri5gnPldXk4HsPF+23GANbE7mjytY8ra3fhXG4VhaFt/WsLg3Bu7djQ0H74y+g= account: mediocregopher --- cmd/dehub/cmd_commit.go | 26 ++----- cmd/dehub/cmd_hook.go | 76 ++++++++------------ cmd/dehub/cmd_verify.go | 13 ++-- cmd/dehub/tmp_file.go | 2 +- commit.go | 102 +++++++++++++++++--------- commit_change.go | 12 ++-- commit_change_test.go | 18 +++-- commit_credential_test.go | 4 +- commit_test.go | 22 +++--- config.go | 2 +- repo.go | 146 +++++++++++++++++++++++++++++++------- repo_test.go | 51 ++++++------- 12 files changed, 280 insertions(+), 194 deletions(-) diff --git a/cmd/dehub/cmd_commit.go b/cmd/dehub/cmd_commit.go index 88e2442..2810efd 100644 --- a/cmd/dehub/cmd_commit.go +++ b/cmd/dehub/cmd_commit.go @@ -46,12 +46,12 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { return fmt.Errorf("accrediting commit: %w", err) } - hash, err := repo.Commit(commit, *accountID) + gitCommit, err := repo.Commit(commit, *accountID) if err != nil { return fmt.Errorf("committing to git: %w", err) } - fmt.Printf("committed to HEAD as %s\n", hash) + fmt.Printf("committed to HEAD as %s\n", gitCommit.GitCommit.Hash) return nil } @@ -110,28 +110,12 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { return nil, errors.New("credential commit cannot have any files changed") } - // TODO maybe nice to have a helper to do all of this - h, err := repo.GitRepo.ResolveRevision(plumbing.Revision(*rev)) + gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev)) if err != nil { - return nil, fmt.Errorf("resolving revision: %w", err) + return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) } - commitObj, err := repo.GitRepo.CommitObject(*h) - if err != nil { - return nil, fmt.Errorf("getting commit object %q: %w", h, err) - } - - var commit dehub.Commit - if err := commit.UnmarshalText([]byte(commitObj.Message)); err != nil { - return nil, fmt.Errorf("unmarshaling commit message: %w", err) - } - - commitInt, err := commit.Interface() - if err != nil { - return nil, fmt.Errorf("casting %#v to CommitInterface: %w", commit, err) - } - - credCommit, err := repo.NewCommitCredential(commitInt.GetHash()) + credCommit, err := repo.NewCommitCredential(gitCommit.Interface.GetHash()) if err != nil { return nil, fmt.Errorf("constructing credential commit: %w", err) } else if err := accreditAndCommit(credCommit); err != nil { diff --git a/cmd/dehub/cmd_hook.go b/cmd/dehub/cmd_hook.go index 121867d..bfa4ce2 100644 --- a/cmd/dehub/cmd_hook.go +++ b/cmd/dehub/cmd_hook.go @@ -29,7 +29,7 @@ func cmdHook(ctx context.Context, cmd *dcmd.Cmd) { for { line, err := br.ReadString('\n') if errors.Is(err, io.EOF) { - return nil, nil + break } else if err != nil { return nil, fmt.Errorf("error reading next line from stdin: %w", err) } @@ -40,64 +40,44 @@ func cmdHook(ctx context.Context, cmd *dcmd.Cmd) { return nil, fmt.Errorf("malformed pre-receive hook stdin line %q", line) } + startRev := plumbing.Revision(lineParts[0]) + endRev := plumbing.Revision(lineParts[1]) branchName := plumbing.ReferenceName(lineParts[2]) - // the zeroRevision gets sent on the very first push - const zeroRevision plumbing.Revision = "0000000000000000000000000000000000000000" + if !branchName.IsBranch() { + return nil, fmt.Errorf("reference %q is not a branch, can't push to it", branchName) + } - fromRev := plumbing.Revision(lineParts[0]) - var fromHash *plumbing.Hash - if fromRev != zeroRevision { - fromHash, err = repo.GitRepo.ResolveRevision(fromRev) + var startHash, endHash plumbing.Hash + // startRev can be a zero hash if these are the first commits for a + // branch being pushed. + if startRev != plumbing.Revision(plumbing.ZeroHash.String()) { + h, err := repo.GitRepo.ResolveRevision(startRev) if err != nil { - return nil, fmt.Errorf("unable to resolve revision %q: %w", fromRev, err) + return nil, fmt.Errorf("resolving revision %q: %w", startRev, err) } + startHash = *h } - - toRev := plumbing.Revision(lineParts[1]) - toHash, err := repo.GitRepo.ResolveRevision(toRev) - if err != nil { - return nil, fmt.Errorf("unable to resolve revision %q: %w", toRev, err) - } - - toCommit, err := repo.GitRepo.CommitObject(*toHash) - if err != nil { - return nil, fmt.Errorf("unable to find commit %q: %w", *toHash, err) - } - - var hashesToCheck []plumbing.Hash - var found bool - for currCommit := toCommit; ; { - hashesToCheck = append(hashesToCheck, currCommit.Hash) - if currCommit.NumParents() == 0 { - break - } else if currCommit.NumParents() > 1 { - return nil, fmt.Errorf("commit %q has more than one parent: %+v", - currCommit.Hash, currCommit.ParentHashes) - } - - parentCommit, err := currCommit.Parent(0) + { + h, err := repo.GitRepo.ResolveRevision(endRev) if err != nil { - return nil, fmt.Errorf("unable to get parent of commit %q: %w", currCommit.Hash, err) - } else if fromHash != nil && parentCommit.Hash == *fromHash { - found = true - break + return nil, fmt.Errorf("resolving revision %q: %w", endRev, err) } - currCommit = parentCommit - } - if !found && fromHash != nil { - return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q", *fromHash, *toHash) + endHash = *h } - for i := len(hashesToCheck) - 1; i >= 0; i-- { - hash := hashesToCheck[i] - fmt.Printf("Verifying change commit %q\n", hash) - if err := repo.VerifyCommit(branchName, hash); err != nil { - return nil, fmt.Errorf("could not verify change commit %q: %w", hash, err) - } + gitCommits, err := repo.GetGitCommitRange(startHash, endHash) + if err != nil { + return nil, fmt.Errorf("getting commits from %q to %q: %w", + startHash, endHash, err) + + } else if err := repo.VerifyCommits(branchName, gitCommits); err != nil { + return nil, fmt.Errorf("verifying commits from %q to %q: %w", + startHash, endHash, err) } - fmt.Println("All pushed commits have been verified, well done.") - return nil, nil } + + fmt.Println("All pushed commits have been verified, well done.") + return nil, nil }) } diff --git a/cmd/dehub/cmd_verify.go b/cmd/dehub/cmd_verify.go index 1ce62d7..30a7f49 100644 --- a/cmd/dehub/cmd_verify.go +++ b/cmd/dehub/cmd_verify.go @@ -2,6 +2,7 @@ package main import ( "context" + "dehub" "dehub/cmd/dehub/dcmd" "fmt" @@ -16,10 +17,11 @@ func cmdVerify(ctx context.Context, cmd *dcmd.Cmd) { cmd.Run(func() (context.Context, error) { repo := ctxRepo(ctx) - h, err := repo.GitRepo.ResolveRevision(plumbing.Revision(*rev)) + gitCommit, err := repo.GetGitRevision(plumbing.Revision(*rev)) if err != nil { - return nil, fmt.Errorf("could not resolve revision %q: %w", *rev, err) + return nil, fmt.Errorf("resolving revision %q: %w", *rev, err) } + gitCommitHash := gitCommit.GitCommit.Hash var branchName plumbing.ReferenceName if *branch == "" { @@ -30,11 +32,12 @@ func cmdVerify(ctx context.Context, cmd *dcmd.Cmd) { branchName = plumbing.NewBranchReferenceName(*branch) } - if err := repo.VerifyCommit(branchName, *h); err != nil { - return nil, fmt.Errorf("could not verify commit at %q (%s): %w", *rev, *h, err) + if err := repo.VerifyCommits(branchName, []dehub.GitCommit{gitCommit}); err != nil { + return nil, fmt.Errorf("could not verify commit at %q (%s): %w", + *rev, gitCommitHash, err) } - fmt.Printf("commit at %q (%s) is good to go!\n", *rev, *h) + fmt.Printf("commit at %q (%s) is good to go!\n", *rev, gitCommitHash) return nil, nil }) } diff --git a/cmd/dehub/tmp_file.go b/cmd/dehub/tmp_file.go index 1fadb7f..0354f8f 100644 --- a/cmd/dehub/tmp_file.go +++ b/cmd/dehub/tmp_file.go @@ -20,7 +20,7 @@ func tmpFileMsg() (string, error) { return "", fmt.Errorf("could not stat EDITOR %q: %w", editor, err) } - tmpf, err := ioutil.TempFile("", "") + tmpf, err := ioutil.TempFile("", "dehub.*.txt") if err != nil { return "", fmt.Errorf("could not open temp file: %w", err) } diff --git a/commit.go b/commit.go index 5c6102b..9746dd5 100644 --- a/commit.go +++ b/commit.go @@ -116,7 +116,7 @@ func (r *Repo) AccreditCommit(commit Commit, sigInt sigcred.SignifierInterface) return commit, fmt.Errorf("could not cast commit %+v to interface: %w", commit, err) } - headFS, err := r.HeadFS() + headFS, err := r.headFS() if err != nil { return commit, fmt.Errorf("could not grab snapshot of HEAD fs: %w", err) } @@ -132,22 +132,31 @@ func (r *Repo) AccreditCommit(commit Commit, sigInt sigcred.SignifierInterface) // Commit uses the given TextMarshaler to create a git commit object (with the // specified accountID as the author) and commits it to the current HEAD, // returning the hash of the commit. -func (r *Repo) Commit(m encoding.TextMarshaler, accountID string) (plumbing.Hash, error) { +func (r *Repo) Commit(m encoding.TextMarshaler, accountID string) (GitCommit, error) { msgB, err := m.MarshalText() if err != nil { - return plumbing.ZeroHash, fmt.Errorf("error marshaling %T to string: %v", m, err) + return GitCommit{}, fmt.Errorf("encoding %T to message string: %v", m, err) } w, err := r.GitRepo.Worktree() if err != nil { - return plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err) + return GitCommit{}, fmt.Errorf("getting git worktree: %w", err) } - return w.Commit(string(msgB), &git.CommitOptions{ + h, err := w.Commit(string(msgB), &git.CommitOptions{ Author: &object.Signature{ Name: accountID, When: time.Now(), }, }) + if err != nil { + return GitCommit{}, fmt.Errorf("committing to git worktree: %w", err) + } + + gc, err := r.GetGitCommit(h) + if err != nil { + return GitCommit{}, fmt.Errorf("retrieving fresh commit %q back from git: %w", h, err) + } + return gc, nil } // HasStagedChanges returns true if there are file changes which have been @@ -176,7 +185,6 @@ func (r *Repo) HasStagedChanges() (bool, error) { type verificationCtx struct { commit *object.Commit commitTree, parentTree *object.Tree - isRootCommit bool } // non-gophers gonna hate on this method, but I say it's fine @@ -188,9 +196,6 @@ func (r *Repo) verificationCtx(h plumbing.Hash) (vctx verificationCtx, err error return vctx, fmt.Errorf("retrieving commit tree object %q: %w", vctx.commit.TreeHash, err) - } else if vctx.isRootCommit = vctx.commit.NumParents() == 0; vctx.isRootCommit { - vctx.parentTree = new(object.Tree) - } else if parent, err := vctx.commit.Parent(0); err != nil { return vctx, fmt.Errorf("retrieving commit parent: %w", err) @@ -258,57 +263,84 @@ func (r *Repo) assertAccessControls( return nil } -// VerifyCommit verifies that the commit at the given hash, which is presumably -// on the given branch, is gucci. -func (r *Repo) VerifyCommit(branch plumbing.ReferenceName, h plumbing.Hash) error { - vctx, err := r.verificationCtx(h) - if err != nil { - return err +// VerifyCommits verifies that the given commits, which are presumably on the +// given branch, are gucci. +func (r *Repo) VerifyCommits(branch plumbing.ReferenceName, gitCommits []GitCommit) error { + + for i, gitCommit := range gitCommits { + // It's not a requirement that the given GitCommits are in ancestral + // order, but usually they are, so we can help verifyCommit not have to + // calculate the parentTree if the previous commit is the parent of this + // one. + var parentTree *object.Tree + if i > 0 && gitCommits[i-1].GitCommit.Hash == gitCommit.GitCommit.ParentHashes[0] { + parentTree = gitCommits[i-1].GitTree + } + + if err := r.verifyCommit(branch, gitCommit, parentTree); err != nil { + return fmt.Errorf("verifying commit %q: %w", + gitCommit.GitCommit.Hash, err) + } + } + return nil +} + +// if parentTree is nil then it will be inferred. +func (r *Repo) verifyCommit(branch plumbing.ReferenceName, gitCommit GitCommit, parentTree *object.Tree) error { + isRoot := gitCommit.Root() + + if parentTree == nil { + if isRoot { + parentTree = new(object.Tree) + } else if parentCommit, err := gitCommit.GitCommit.Parent(0); err != nil { + return fmt.Errorf("getting parent commit %q: %w", + gitCommit.GitCommit.ParentHashes[0], err) + } else if parentTree, err = r.GitRepo.TreeObject(parentCommit.TreeHash); err != nil { + return fmt.Errorf("getting parent tree object %q: %w", + parentCommit.TreeHash, err) + } + } + + vctx := verificationCtx{ + commit: gitCommit.GitCommit, + commitTree: gitCommit.GitTree, + parentTree: parentTree, } var sigFS fs.FS - if vctx.isRootCommit { + if isRoot { sigFS = fs.FromTree(vctx.commitTree) } else { sigFS = fs.FromTree(vctx.parentTree) } - var commit Commit - if err := commit.UnmarshalText([]byte(vctx.commit.Message)); err != nil { - return err - } - cfg, err := r.loadConfig(sigFS) if err != nil { - return fmt.Errorf("error loading config: %w", err) + return fmt.Errorf("loading config of parent %q: %w", + gitCommit.GitCommit.ParentHashes[0], err) } - err = r.assertAccessControls(cfg.AccessControls, commit, vctx, branch) - if err != nil { - return fmt.Errorf("failed to satisfy all access controls: %w", err) - } - - commitInt, err := commit.Interface() + err = r.assertAccessControls(cfg.AccessControls, gitCommit.Commit, vctx, branch) if err != nil { - return fmt.Errorf("could not cast commit %+v to interface: %w", commit, err) + return fmt.Errorf("enforcing access controls: %w", err) } - changeHash := commitInt.GetHash() - expectedChangeHash, err := commitInt.Hash(vctx.parentTree, vctx.commitTree) + changeHash := gitCommit.Interface.GetHash() + expectedChangeHash, err := gitCommit.Interface.Hash(vctx.parentTree, vctx.commitTree) if err != nil { - return fmt.Errorf("error calculating expected change hash: %w", err) + return fmt.Errorf("calculating expected change hash: %w", err) } else if !bytes.Equal(changeHash, expectedChangeHash) { return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s", base64.StdEncoding.EncodeToString(expectedChangeHash), base64.StdEncoding.EncodeToString(changeHash)) } - for _, cred := range commit.Credentials { + for _, cred := range gitCommit.Commit.Credentials { sig, err := r.signifierForCredential(sigFS, cred) if err != nil { - return fmt.Errorf("error finding signifier for credential %+v: %w", cred, err) + return fmt.Errorf("finding signifier for credential %+v: %w", cred, err) } else if err := sig.Verify(sigFS, expectedChangeHash, cred); err != nil { - return fmt.Errorf("error verifying credential %+v: %w", cred, err) + return fmt.Errorf("verifying credential %+v: %w", cred, err) } } diff --git a/commit_change.go b/commit_change.go index b7b4c17..61628ee 100644 --- a/commit_change.go +++ b/commit_change.go @@ -4,9 +4,9 @@ import ( "dehub/fs" "dehub/yamlutil" "errors" + "fmt" "strings" - "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/object" ) @@ -22,11 +22,11 @@ var _ CommitInterface = CommitChange{} // encompassing the currently staged file changes. The Credentials of the // returned Commit will _not_ be filled in. func (r *Repo) NewCommitChange(msg string) (Commit, error) { - _, headTree, err := r.head() - if errors.Is(err, plumbing.ErrReferenceNotFound) { - headTree = &object.Tree{} - } else if err != nil { - return Commit{}, err + headTree := new(object.Tree) + if head, err := r.GetGitHead(); err != nil && !errors.Is(err, ErrNoHead) { + return Commit{}, fmt.Errorf("getting HEAD commit: %w", err) + } else if err == nil { + headTree = head.GitTree } _, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo) diff --git a/commit_change_test.go b/commit_change_test.go index c739209..b1ec119 100644 --- a/commit_change_test.go +++ b/commit_change_test.go @@ -76,24 +76,22 @@ func TestChangeCommitVerify(t *testing.T) { h.stage(step.tree) account := h.cfg.Accounts[0] - commit, hash := h.changeCommit(step.msg, account.ID, h.sig) - commitObj, err := h.repo.GitRepo.CommitObject(hash) - if err != nil { - t.Fatalf("failed to retrieve commit %v: %v", hash, err) - } else if step.msgHead == "" { + gitCommit := h.changeCommit(step.msg, account.ID, h.sig) + if step.msgHead == "" { step.msgHead = strings.TrimSpace(step.msg) + "\n\n" } - if !strings.HasPrefix(commitObj.Message, step.msgHead) { - t.Fatalf("commit message %q does not start with expected head %q", commitObj.Message, step.msgHead) + if !strings.HasPrefix(gitCommit.GitCommit.Message, step.msgHead) { + t.Fatalf("commit message %q does not start with expected head %q", + gitCommit.GitCommit.Message, step.msgHead) } var actualCommit Commit - if err := actualCommit.UnmarshalText([]byte(commitObj.Message)); err != nil { + if err := actualCommit.UnmarshalText([]byte(gitCommit.GitCommit.Message)); err != nil { t.Fatalf("error unmarshaling commit body: %v", err) - } else if !reflect.DeepEqual(actualCommit, commit) { + } else if !reflect.DeepEqual(actualCommit, gitCommit.Commit) { t.Fatalf("returned change commit:\n%s\ndoes not match actual one:\n%s", - spew.Sdump(commit), spew.Sdump(actualCommit)) + spew.Sdump(gitCommit.Commit), spew.Sdump(actualCommit)) } } }) diff --git a/commit_credential_test.go b/commit_credential_test.go index f7dec4a..3e6d7e2 100644 --- a/commit_credential_test.go +++ b/commit_credential_test.go @@ -61,11 +61,11 @@ func TestCredentialCommitVerify(t *testing.T) { }, } h.stageCfg() - rootCommit, _ := h.changeCommit("initial commit", h.cfg.Accounts[0].ID, h.sig) + rootGitCommit := h.changeCommit("initial commit", h.cfg.Accounts[0].ID, h.sig) // toot user wants to create a credential commit for the root commit, for // whatever reason. - rootChangeHash := rootCommit.Change.ChangeHash + rootChangeHash := rootGitCommit.Commit.Change.ChangeHash credCommit, err := h.repo.NewCommitCredential(rootChangeHash) if err != nil { t.Fatalf("creating credential commit for hash %x: %v", rootChangeHash, err) diff --git a/commit_test.go b/commit_test.go index 195cbb9..65b995d 100644 --- a/commit_test.go +++ b/commit_test.go @@ -3,19 +3,17 @@ package dehub import ( "dehub/sigcred" "testing" - - "gopkg.in/src-d/go-git.v4/plumbing" ) func TestConfigChange(t *testing.T) { h := newHarness(t) - var hashes []plumbing.Hash + var gitCommits []GitCommit // commit the initial staged changes, which merely include the config and // public key - _, hash := h.changeCommit("commit configuration", h.cfg.Accounts[0].ID, h.sig) - hashes = append(hashes, hash) + gitCommit := h.changeCommit("commit configuration", h.cfg.Accounts[0].ID, h.sig) + gitCommits = append(gitCommits, gitCommit) // create a new account and add it to the configuration. That commit should // not be verifiable, though @@ -38,17 +36,15 @@ func TestConfigChange(t *testing.T) { // now add with the root user, this should work. h.stageCfg() - _, hash = h.changeCommit("add toot user", h.cfg.Accounts[0].ID, h.sig) - hashes = append(hashes, hash) + gitCommit = h.changeCommit("add toot user", h.cfg.Accounts[0].ID, h.sig) + gitCommits = append(gitCommits, gitCommit) // _now_ the toot user should be able to do things. h.stage(map[string]string{"foo/bar": "what a cool file"}) - _, hash = h.changeCommit("add a cool file", h.cfg.Accounts[1].ID, newSig) - hashes = append(hashes, hash) + gitCommit = h.changeCommit("add a cool file", h.cfg.Accounts[1].ID, newSig) + gitCommits = append(gitCommits, gitCommit) - for i, hash := range hashes { - if err := h.repo.VerifyCommit(MainRefName, hash); err != nil { - t.Fatalf("commit %d (%v) should have been verified but wasn't: %v", i, hash, err) - } + if err := h.repo.VerifyCommits(MainRefName, gitCommits); err != nil { + t.Fatal(err) } } diff --git a/config.go b/config.go index f65e9e3..ceda7d1 100644 --- a/config.go +++ b/config.go @@ -44,7 +44,7 @@ func (r *Repo) loadConfig(fs fs.FS) (Config, error) { // LoadConfig loads the Config object from the HEAD of the repo, or directly // from the filesystem if there is no HEAD yet. func (r *Repo) LoadConfig() (Config, error) { - headFS, err := r.HeadFS() + headFS, err := r.headFS() if err != nil { return Config{}, fmt.Errorf("error retrieving repo HEAD: %w", err) } diff --git a/repo.go b/repo.go index b7ee3b0..80c1659 100644 --- a/repo.go +++ b/repo.go @@ -95,7 +95,7 @@ func InitMemRepo() *Repo { func (r *Repo) init() error { h := plumbing.NewSymbolicReference(plumbing.HEAD, MainRefName) if err := r.GitRepo.Storer.SetReference(h); err != nil { - return fmt.Errorf("could not set HEAD to %q: %w", MainRefName, err) + return fmt.Errorf("setting HEAD reference to %q: %w", MainRefName, err) } return nil } @@ -103,7 +103,7 @@ func (r *Repo) init() error { func (r *Repo) billyFilesystem() (billy.Filesystem, error) { w, err := r.GitRepo.Worktree() if err != nil { - return nil, fmt.Errorf("could not open git worktree: %w", err) + return nil, fmt.Errorf("opening git worktree: %w", err) } return w.Filesystem, nil } @@ -114,7 +114,7 @@ func (r *Repo) CheckedOutBranch() (plumbing.ReferenceName, error) { // newly initialized repo very well. ogRef, err := r.GitRepo.Storer.Reference(plumbing.HEAD) if err != nil { - return "", fmt.Errorf("couldn't de-reference HEAD (is it a bare repo?): %w", err) + return "", fmt.Errorf("de-referencing HEAD (is it a bare repo?): %w", err) } ref := ogRef @@ -137,39 +137,137 @@ func (r *Repo) CheckedOutBranch() (plumbing.ReferenceName, error) { return "", fmt.Errorf("could not de-reference HEAD to a branch: %w", err) } -func (r *Repo) head() (*object.Commit, *object.Tree, error) { - head, err := r.GitRepo.Head() +// headFS returns an FS based on the HEAD commit, or if there is no HEAD commit +// (it's an empty repo) an FS based on the raw filesystem. +func (r *Repo) headFS() (fs.FS, error) { + head, err := r.GetGitHead() + if errors.Is(err, ErrNoHead) { + bfs, err := r.billyFilesystem() + if err != nil { + return nil, fmt.Errorf("getting underlying filesystem: %w", err) + } + return fs.FromBillyFilesystem(bfs), nil + + } else if err != nil { + return nil, fmt.Errorf("could not get HEAD tree: %w", err) + } + + return fs.FromTree(head.GitTree), nil +} + +// GitCommit wraps a single git commit object, and also contains various fields +// which are parsed out of it. It is used as a convenience type, in place of +// having to manually retrieve and parse specific information out of commit +// objects. +type GitCommit struct { + GitCommit *object.Commit + + // Fields based on that Commit, which can't be directly gleaned from it. + GitTree *object.Tree + Commit Commit + Interface CommitInterface +} + +// Root returns true if this commit is the root commit in its branch (i.e. it +// has no parents) +func (gc GitCommit) Root() bool { + return gc.GitCommit.NumParents() == 0 +} + +// GetGitCommit retrieves the commit at the given hash, and all of its sub-data +// which can be pulled out of it. +func (r *Repo) GetGitCommit(h plumbing.Hash) (gc GitCommit, err error) { + if gc.GitCommit, err = r.GitRepo.CommitObject(h); err != nil { + return gc, fmt.Errorf("getting git commit object: %w", err) + } else if gc.GitTree, err = r.GitRepo.TreeObject(gc.GitCommit.TreeHash); err != nil { + return gc, fmt.Errorf("getting git tree object %q: %w", + gc.GitCommit.TreeHash, err) + } else if gc.Commit.UnmarshalText([]byte(gc.GitCommit.Message)); err != nil { + return gc, fmt.Errorf("decoding commit message: %w", err) + } else if gc.Interface, err = gc.Commit.Interface(); err != nil { + return gc, fmt.Errorf("casting %+v to a CommitInterface: %w", gc.Commit, err) + } + return +} + +// GetGitRevision resolves the revision and returns the GitCommit it references. +func (r *Repo) GetGitRevision(rev plumbing.Revision) (GitCommit, error) { + // This returns a pointer for some reason, not sure why. + h, err := r.GitRepo.ResolveRevision(rev) if err != nil { - return nil, nil, fmt.Errorf("could not get repo HEAD: %w", err) + return GitCommit{}, fmt.Errorf("resolving revision: %w", err) } + gc, err := r.GetGitCommit(*h) + if err != nil { + return GitCommit{}, fmt.Errorf("getting commit %q: %w", *h, err) + } + return gc, nil +} + +// ErrNoHead is returns from GetGitHead if there is no HEAD reference defined in +// the repo. This can happen if the repo has no commits +var ErrNoHead = errors.New("HEAD reference not found") + +// GetGitHead returns the GitCommit which is currently referenced by HEAD. +// This method may return ErrNoHead if the repo has no commits. +func (r *Repo) GetGitHead() (GitCommit, error) { + head, err := r.GitRepo.Head() + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return GitCommit{}, ErrNoHead + } else if err != nil { + return GitCommit{}, fmt.Errorf("resolving HEAD: %w", err) + } headHash := head.Hash() - headCommit, err := r.GitRepo.CommitObject(headHash) + + gc, err := r.GetGitCommit(headHash) if err != nil { - return nil, nil, fmt.Errorf("could not get commit at HEAD (%q): %w", headHash, err) + return GitCommit{}, fmt.Errorf("getting commit %q: %w", headHash, err) } + return gc, nil +} - headTree, err := r.GitRepo.TreeObject(headCommit.TreeHash) +// GetGitCommitRange returns an ancestry of GitCommits, with the first being the +// commit immediately following the given starting hash, and the last being the +// given ending hash. +// +// If start is plumbing.ZeroHash then the root commit will be the starting one. +func (r *Repo) GetGitCommitRange(start, end plumbing.Hash) ([]GitCommit, error) { + curr, err := r.GetGitCommit(end) if err != nil { - return nil, nil, fmt.Errorf("could not get tree object at HEAD (commit:%q tree:%q): %w", - headHash, headCommit.TreeHash, err) + return nil, fmt.Errorf("retrieving commit %q: %w", end, err) } - return headCommit, headTree, nil -} + var commits []GitCommit + var found bool + for { + commits = append(commits, curr) + numParents := curr.GitCommit.NumParents() + if numParents == 0 { + break + } else if numParents > 1 { + return nil, fmt.Errorf("commit %q has more than one parent: %+v", + curr.GitCommit.Hash, curr.GitCommit.ParentHashes) + } -// HeadFS returns an FS based on the HEAD commit, or if there is no HEAD commit -// (it's an empty repo) an FS based on the raw filesystem. -func (r *Repo) HeadFS() (fs.FS, error) { - _, headTree, err := r.head() - if errors.Is(err, plumbing.ErrReferenceNotFound) { - bfs, err := r.billyFilesystem() + parentHash := curr.GitCommit.ParentHashes[0] + parent, err := r.GetGitCommit(parentHash) if err != nil { - return nil, fmt.Errorf("could not get underlying filesystem: %w", err) + return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err) + } else if start != plumbing.ZeroHash && parentHash == start { + found = true + break } - return fs.FromBillyFilesystem(bfs), nil - } else if err != nil { - return nil, fmt.Errorf("could not get HEAD tree: %w", err) + curr = parent + } + if !found && start != plumbing.ZeroHash { + return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q", + start, end) + } + + // reverse the commits to be in the expected order + for l, r := 0, len(commits)-1; l < r; l, r = l+1, r-1 { + commits[l], commits[r] = commits[r], commits[l] } - return fs.FromTree(headTree), nil + return commits, nil } diff --git a/repo_test.go b/repo_test.go index 6936279..e64647d 100644 --- a/repo_test.go +++ b/repo_test.go @@ -122,7 +122,7 @@ func (h *harness) checkout(branch plumbing.ReferenceName) { h.t.Fatal(err) } - head, _, err := h.repo.head() + head, err := h.repo.GetGitHead() if err != nil { h.t.Fatal(err) } @@ -130,7 +130,7 @@ func (h *harness) checkout(branch plumbing.ReferenceName) { _, err = h.repo.GitRepo.Branch(branch.Short()) if errors.Is(err, git.ErrBranchNotFound) { err = w.Checkout(&git.CheckoutOptions{ - Hash: head.Hash, + Hash: head.GitCommit.Hash, Branch: branch, Create: true, }) @@ -166,9 +166,7 @@ func (h *harness) tryCommit( shouldSucceed bool, commit Commit, accountID string, accountSig sigcred.SignifierInterface, -) ( - Commit, plumbing.Hash, -) { +) GitCommit { if accountSig != nil { var err error if commit, err = h.repo.AccreditCommit(commit, accountSig); err != nil { @@ -176,7 +174,7 @@ func (h *harness) tryCommit( } } - hash, err := h.repo.Commit(commit, accountID) + gitCommit, err := h.repo.Commit(commit, accountID) if err != nil { h.t.Fatalf("failed to commit ChangeCommit: %v", err) } @@ -186,35 +184,28 @@ func (h *harness) tryCommit( h.t.Fatalf("determining checked out branch: %v", err) } - err = h.repo.VerifyCommit(branch, hash) + err = h.repo.VerifyCommits(branch, []GitCommit{gitCommit}) if shouldSucceed && err != nil { - h.t.Fatalf("verifying commit %q: %v", hash, err) + h.t.Fatalf("verifying commit %q: %v", gitCommit.GitCommit.Hash, err) } else if shouldSucceed { - return commit, hash + return gitCommit } else if !shouldSucceed && err == nil { - h.t.Fatalf("verifying commit %q should have failed", hash) + h.t.Fatalf("verifying commit %q should have failed", gitCommit.GitCommit.Hash) } - // commit verifying didn't succeed, reset it back. first get parent commit - // to reset to - commitObj, err := h.repo.GitRepo.CommitObject(hash) - if err != nil { - h.t.Fatalf("getting commit object of unverifiable hash %q: %v", hash, err) - } else if commitObj.NumParents() == 0 { - h.t.Fatalf("unverifiable commit %q has no parents, but it should", hash) + if gitCommit.GitCommit.NumParents() == 0 { + h.t.Fatalf("unverifiable commit %q has no parents, but it should", gitCommit.GitCommit.NumParents()) } - h.reset(commitObj.ParentHashes[0], git.HardReset) - return commit, hash + h.reset(gitCommit.GitCommit.ParentHashes[0], git.HardReset) + return gitCommit } func (h *harness) changeCommit( msg string, accountID string, sig sigcred.SignifierInterface, -) ( - Commit, plumbing.Hash, -) { +) GitCommit { commit, err := h.repo.NewCommitChange(msg) if err != nil { h.t.Fatalf("creating ChangeCommit: %v", err) @@ -304,9 +295,15 @@ func TestThisRepoStillVerifies(t *testing.T) { t.Fatalf("error opening repo: %v", err) } - headCommit, _, err := repo.head() + headGitCommit, err := repo.GetGitHead() + if err != nil { + t.Fatalf("getting repo head: %v", err) + } + + allCommits, err := repo.GetGitCommitRange(plumbing.ZeroHash, headGitCommit.GitCommit.Hash) if err != nil { - t.Fatalf("error getting repo head: %v", err) + t.Fatalf("getting all commits (up to %q): %v", + headGitCommit.GitCommit.Hash, err) } checkedOutBranch, err := repo.CheckedOutBranch() @@ -314,9 +311,7 @@ func TestThisRepoStillVerifies(t *testing.T) { t.Fatalf("error determining checked out branch: %v", err) } - for _, hash := range headCommit.ParentHashes { - if err := repo.VerifyCommit(checkedOutBranch, hash); err != nil { - t.Fatalf("error verifying commit %q of branch %q: %v", hash, checkedOutBranch, err) - } + if err := repo.VerifyCommits(checkedOutBranch, allCommits); err != nil { + t.Fatal(err) } }