diff --git a/cmd/dehub/cmd_commit.go b/cmd/dehub/cmd_commit.go index eb2b19f..f02139c 100644 --- a/cmd/dehub/cmd_commit.go +++ b/cmd/dehub/cmd_commit.go @@ -152,3 +152,33 @@ func cmdCommit(ctx context.Context, cmd *dcmd.Cmd) { cmd.Run(body) } + +func cmdCombine(ctx context.Context, cmd *dcmd.Cmd) { + flag := cmd.FlagSet() + onto := flag.String("onto", "", "Branch the new commit should be put onto") + startRev := flag.String("start", "", "Revision of the starting commit to combine") + endRev := flag.String("end", "", "Revision of the ending commit to combine") + + cmd.Run(func() (context.Context, error) { + if *onto == "" || + *startRev == "" || + *endRev == "" { + return nil, errors.New("-onto, -start, and -end are required") + } + + repo := ctxRepo(ctx) + ontoBranch := plumbing.NewBranchReferenceName(*onto) + gitCommit, err := repo.CombineCommitChanges( + plumbing.Revision(*startRev), + plumbing.Revision(*endRev), + ontoBranch, + ) + if err != nil { + return nil, err + } + + fmt.Printf("new commit %q added to branch %q\n", + gitCommit.GitCommit.Hash, ontoBranch.Short()) + return nil, nil + }) +} diff --git a/cmd/dehub/cmd_hook.go b/cmd/dehub/cmd_hook.go index bfa4ce2..88a3b2d 100644 --- a/cmd/dehub/cmd_hook.go +++ b/cmd/dehub/cmd_hook.go @@ -48,32 +48,14 @@ func cmdHook(ctx context.Context, cmd *dcmd.Cmd) { return nil, fmt.Errorf("reference %q is not a branch, can't push to it", branchName) } - 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("resolving revision %q: %w", startRev, err) - } - startHash = *h - } - { - h, err := repo.GitRepo.ResolveRevision(endRev) - if err != nil { - return nil, fmt.Errorf("resolving revision %q: %w", endRev, err) - } - endHash = *h - } - - gitCommits, err := repo.GetGitCommitRange(startHash, endHash) + gitCommits, err := repo.GetGitRevisionRange(startRev, endRev) if err != nil { return nil, fmt.Errorf("getting commits from %q to %q: %w", - startHash, endHash, err) + startRev, endRev, err) } else if err := repo.VerifyCommits(branchName, gitCommits); err != nil { return nil, fmt.Errorf("verifying commits from %q to %q: %w", - startHash, endHash, err) + startRev, endRev, err) } } diff --git a/cmd/dehub/main.go b/cmd/dehub/main.go index 5f478d7..5fc7d96 100644 --- a/cmd/dehub/main.go +++ b/cmd/dehub/main.go @@ -30,6 +30,7 @@ func main() { cmd.SubCmd("commit", "commits staged changes to the head of the current branch", cmdCommit) cmd.SubCmd("verify", "verifies one or more commits as having the proper credentials", cmdVerify) cmd.SubCmd("hook", "use dehub as a git hook", cmdHook) + cmd.SubCmd("combine", "Combine multiple change and credential commits into a single commit", cmdCombine) cmd.Run(func() (context.Context, error) { repo, err := dehub.OpenRepo(".", dehub.OpenBare(*bare)) diff --git a/commit_change.go b/commit_change.go index e66d3ab..116b38a 100644 --- a/commit_change.go +++ b/commit_change.go @@ -1,11 +1,16 @@ package dehub import ( + "bytes" "dehub/fs" + "dehub/sigcred" "dehub/yamlutil" "errors" "fmt" + "sort" + "strings" + "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/object" ) @@ -57,3 +62,102 @@ func (cc CommitChange) Hash(parent, this *object.Tree) ([]byte, error) { func (cc CommitChange) GetHash() []byte { return cc.ChangeHash } + +// CombineCommitChanges takes all changes in the given range and combines them +// into a single change Commit. The resulting Commit will have the same message +// as the latest change commit in the range, and will contain all Credentials +// for the resulting change hash that it finds in the range as well. +// +// The combined commit is then committed to the repo with the given revision as +// its parent. If the diff between start/end and onto/end is different then this +// will return an error, as the change hash which has been accredited in +// start/end will be different than the one which needs to be accredited in +// onto/end. +func (r *Repo) CombineCommitChanges(startRev, endRev plumbing.Revision, onto plumbing.ReferenceName) (GitCommit, error) { + startEndCommits, err := r.GetGitRevisionRange(startRev, endRev) + if err != nil { + return GitCommit{}, fmt.Errorf("retrieving commits %q to %q: %w", startRev, endRev, err) + } + + var lastChangeCommit GitCommit + var lastChangeCommitOk bool + for i := len(startEndCommits) - 1; i >= 0; i-- { + if _, lastChangeCommitOk = startEndCommits[i].Interface.(*CommitChange); lastChangeCommitOk { + lastChangeCommit = startEndCommits[i] + break + } + } + if !lastChangeCommitOk { + return GitCommit{}, fmt.Errorf("no change commits in range %q to %q", startRev, endRev) + } + + // startTree has to be the tree of startRev, which isn't included in + // startEndCommits. Determine it the hard way. + startTree, err := r.parentTree(startEndCommits[0].GitCommit) + if err != nil { + return GitCommit{}, fmt.Errorf("getting tree of %q (parent of %q): %w", + startRev, startEndCommits[0].GitCommit.Hash, err) + } + + msg := lastChangeCommit.Commit.Change.Message + endTree := lastChangeCommit.GitTree + changeHash := genChangeHash(nil, msg, startTree, endTree) + + ontoBranchName, err := r.ReferenceToBranchName(onto) + if err != nil { + return GitCommit{}, fmt.Errorf("resolving %q into a branch name: %w", onto, err) + } + + // now determine the change hash from onto->end, to ensure that it remains + // the same as from start->end + ontoCommit, err := r.GetGitRevision(plumbing.Revision(onto)) + if err != nil { + return GitCommit{}, fmt.Errorf("resolving revision %q: %w", onto, err) + } + ontoTree := ontoCommit.GitTree + ontoEndChangeHash := genChangeHash(nil, msg, ontoTree, endTree) + if !bytes.Equal(ontoEndChangeHash, changeHash) { + // TODO figure out what files to show as being the "problem files" in + // the error message + return GitCommit{}, fmt.Errorf("rebasing onto %q would cause the change hash to change, aborting combine", onto) + } + + var creds []sigcred.Credential + for _, commit := range startEndCommits { + if bytes.Equal(commit.Interface.GetHash(), changeHash) { + creds = append(creds, commit.Commit.Common.Credentials...) + } + } + + // this is mostly to make tests easier + sort.Slice(creds, func(i, j int) bool { + return creds[i].AccountID < creds[j].AccountID + }) + + commit := Commit{ + Change: &CommitChange{ + Message: msg, + ChangeHash: changeHash, + }, + Common: CommitCommon{Credentials: creds}, + } + accountID := strings.Join(commit.Common.credAccountIDs(), ",") + + gitCommit, err := r.CommitBare(CommitBareParams{ + Commit: commit, + AccountID: accountID, + ParentHash: ontoCommit.GitCommit.Hash, + GitTree: endTree, + }) + if err != nil { + return GitCommit{}, fmt.Errorf("storing commit: %w", err) + } + + // set the onto branch to this new commit + newHeadRef := plumbing.NewHashReference(ontoBranchName, gitCommit.GitCommit.Hash) + if err := r.GitRepo.Storer.SetReference(newHeadRef); err != nil { + return GitCommit{}, fmt.Errorf("setting reference %q to new commit hash %q: %w", + ontoBranchName, gitCommit.GitCommit.Hash, err) + } + return gitCommit, nil +} diff --git a/commit_change_test.go b/commit_change_test.go index b1ec119..032d576 100644 --- a/commit_change_test.go +++ b/commit_change_test.go @@ -1,11 +1,14 @@ package dehub import ( + "dehub/sigcred" "reflect" "strings" "testing" "github.com/davecgh/go-spew/spew" + "gopkg.in/src-d/go-git.v4/plumbing" + yaml "gopkg.in/yaml.v2" ) func TestChangeCommitVerify(t *testing.T) { @@ -97,3 +100,93 @@ func TestChangeCommitVerify(t *testing.T) { }) } } + +func TestCombineCommitChanges(t *testing.T) { + h := newHarness(t) + + // commit initial config, so the root user can modify it in the next commit + h.changeCommit("initial commit", h.cfg.Accounts[0].ID, h.sig) + + // add a toot user and modify the access controls such that both accounts + // are required for the main branch + tootSig, tootPubKeyBody := sigcred.SignifierPGPTmp("toot", h.rand) + h.cfg.Accounts = append(h.cfg.Accounts, Account{ + ID: "toot", + Signifiers: []sigcred.Signifier{{PGPPublicKey: &sigcred.SignifierPGP{ + Body: string(tootPubKeyBody), + }}}, + }) + err := yaml.Unmarshal([]byte(` +- action: allow + filters: + - type: branch + pattern: main + - type: commit_type + commit_type: change + - type: signature + any_account: true + count: 2 + +- action: allow + filters: + - type: not + filter: + type: branch + pattern: main + - type: signature + any_account: true + count: 1 + +- action: deny +`), &h.cfg.AccessControls) + if err != nil { + t.Fatal(err) + } + + h.stageCfg() + tootCommit := h.changeCommit("add toot", h.cfg.Accounts[0].ID, h.sig) + + // make a single change commit in another branch using root. Then add a + // credential using toot, and combine them onto main. + otherBranch := plumbing.NewBranchReferenceName("other") + h.checkout(otherBranch) + h.stage(map[string]string{"foo": "bar"}) + fooCommit := h.changeCommit("add foo file", h.cfg.Accounts[0].ID, h.sig) + + // now adding a credential commit from toot should work + credCommitObj, err := h.repo.NewCommitCredential(fooCommit.Interface.GetHash()) + if err != nil { + t.Fatal(err) + } + credCommit := h.tryCommit(true, credCommitObj, h.cfg.Accounts[1].ID, tootSig) + combinedCommit, err := h.repo.CombineCommitChanges( + plumbing.Revision(tootCommit.GitCommit.Hash.String()), + plumbing.Revision(credCommit.GitCommit.Hash.String()), + MainRefName, + ) + if err != nil { + t.Fatal(err) + } + + // that new commit should have both credentials + creds := combinedCommit.Commit.Common.Credentials + if len(creds) != 2 { + t.Fatalf("combined commit has %d credentials, not 2", len(creds)) + } else if creds[0].AccountID != "root" { + t.Fatalf("combined commit first credential should be from root, is from %q", creds[0].AccountID) + } else if creds[1].AccountID != "toot" { + t.Fatalf("combined commit second credential should be from toot, is from %q", creds[1].AccountID) + } + + // double check that the HEAD commit of main got properly set + h.checkout(MainRefName) + mainHead, err := h.repo.GetGitHead() + if err != nil { + t.Fatal(err) + } else if mainHead.GitCommit.Hash != combinedCommit.GitCommit.Hash { + t.Fatalf("mainHead's should be pointed at %s but is pointed at %s", + combinedCommit.GitCommit.Hash, mainHead.GitCommit.Hash) + } else if err = h.repo.VerifyCommits(MainRefName, []GitCommit{combinedCommit}); err != nil { + t.Fatalf("unable to verify combined commit: %v", err) + } +} diff --git a/repo.go b/repo.go index a6d6920..9d2cd22 100644 --- a/repo.go +++ b/repo.go @@ -136,10 +136,15 @@ func (r *Repo) TraverseReferenceChain(refName plumbing.ReferenceName, pred func( } } -// ReferenceToBranchName traverses a chain of references looking for a branch -// reference, and returns that name, or returns an error if no branch reference -// is part of the chain. +// ReferenceToBranchName traverses a chain of references looking for the first +// branch reference, and returns that name, or returns an error if no branch +// reference is part of the chain. func (r *Repo) ReferenceToBranchName(refName plumbing.ReferenceName) (plumbing.ReferenceName, error) { + // first check if the given refName is a branch, if so just return that. + if refName.IsBranch() { + return refName, nil + } + ref, err := r.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool { return ref.Target().IsBranch() }) @@ -301,3 +306,30 @@ func (r *Repo) GetGitCommitRange(start, end plumbing.Hash) ([]GitCommit, error) } return commits, nil } + +func (r *Repo) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) { + if rev == plumbing.Revision(plumbing.ZeroHash.String()) { + return plumbing.ZeroHash, nil + } + h, err := r.GitRepo.ResolveRevision(rev) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err) + } + return *h, nil +} + +// GetGitRevisionRange is like GetGitCommitRange, first resolving the given +// revisions into hashes before continuing with GetGitCommitRange's behavior. +func (r *Repo) GetGitRevisionRange(startRev, endRev plumbing.Revision) ([]GitCommit, error) { + start, err := r.resolveRev(startRev) + if err != nil { + return nil, err + } + + end, err := r.resolveRev(endRev) + if err != nil { + return nil, err + } + + return r.GetGitCommitRange(start, end) +} diff --git a/repo_test.go b/repo_test.go index 3c855f7..3f5ab19 100644 --- a/repo_test.go +++ b/repo_test.go @@ -110,8 +110,8 @@ func (h *harness) checkout(branch plumbing.ReferenceName) { h.t.Fatal(err) } - _, err = h.repo.GitRepo.Branch(branch.Short()) - if errors.Is(err, git.ErrBranchNotFound) { + _, err = h.repo.GitRepo.Storer.Reference(branch) + if errors.Is(err, plumbing.ErrReferenceNotFound) { err = w.Checkout(&git.CheckoutOptions{ Hash: head.GitCommit.Hash, Branch: branch,