more experiment

Michael Muré created

Change summary

commands/commands.go    |  40 ++
commands/pull.go        |  33 +
commands/push.go        |  33 +
git-bug.go              |  72 +++
notes                   |  47 ++
repository/git.go       | 838 +++++++++++++++++++++++++++++++++++++++++++
repository/mock_repo.go |  33 +
repository/repo.go      |  20 +
8 files changed, 1,114 insertions(+), 2 deletions(-)

Detailed changes

commands/commands.go 🔗

@@ -0,0 +1,40 @@
+// Package commands contains the assorted sub commands supported by the git-bug tool.
+package commands
+
+import (
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+const bugsRefPattern = "refs/bugs/*"
+
+// Command represents the definition of a single command.
+type Command struct {
+	Usage     func(string)
+	RunMethod func(repository.Repo, []string) error
+}
+
+// Run executes a command, given its arguments.
+//
+// The args parameter is all of the command line args that followed the
+// subcommand.
+func (cmd *Command) Run(repo repository.Repo, args []string) error {
+	return cmd.RunMethod(repo, args)
+}
+
+// CommandMap defines all of the available (sub)commands.
+var CommandMap = map[string]*Command{
+	"pull": pullCmd,
+	"push": pushCmd,
+
+	/*"abandon": abandonCmd,
+	"accept":  acceptCmd,
+	"comment": commentCmd,
+	"list":    listCmd,
+	"pull":    pullCmd,
+	"push":    pushCmd,
+	"rebase":  rebaseCmd,
+	"reject":  rejectCmd,
+	"request": requestCmd,
+	"show":    showCmd,
+	"submit":  submitCmd,*/
+}

commands/pull.go 🔗

@@ -0,0 +1,33 @@
+package  commands
+
+import (
+	"fmt"
+	"github.com/MichaelMure/git-bug/repository"
+	"errors"
+)
+
+func pull(repo repository.Repo, args []string) error {
+	if len(args) > 1 {
+		return errors.New("only pulling from one remote at a time is supported")
+	}
+
+	remote := "origin"
+	if len(args) == 1 {
+		remote = args[0]
+	}
+
+	if err := repo.PullRefs(remote, bugsRefPattern); err != nil {
+		return err
+	}
+	return nil
+}
+
+// showCmd defines the "push" subcommand.
+var pullCmd = &Command{
+	Usage: func(arg0 string) {
+		fmt.Printf("Usage: %s pull [<remote>]\n", arg0)
+	},
+	RunMethod: func(repo repository.Repo, args []string) error {
+		return pull(repo, args)
+	},
+}

commands/push.go 🔗

@@ -0,0 +1,33 @@
+package  commands
+
+import (
+	"fmt"
+	"github.com/MichaelMure/git-bug/repository"
+	"errors"
+)
+
+func push(repo repository.Repo, args []string) error {
+	if len(args) > 1 {
+		return errors.New("only pushing to one remote at a time is supported")
+	}
+
+	remote := "origin"
+	if len(args) == 1 {
+		remote = args[0]
+	}
+
+	if err := repo.PushRefs(remote, bugsRefPattern); err != nil {
+		return err
+	}
+	return nil
+}
+
+// showCmd defines the "push" subcommand.
+var pushCmd = &Command{
+	Usage: func(arg0 string) {
+		fmt.Printf("Usage: %s push [<remote>]\n", arg0)
+	},
+	RunMethod: func(repo repository.Repo, args []string) error {
+		return push(repo, args)
+	},
+}

git-bug.go 🔗

@@ -1,7 +1,75 @@
 package main
 
-import "fmt"
+import (
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/commands"
+)
+
+const usageMessageTemplate = `Usage: %s <command>
+
+Where <command> is one of:
+  %s
+
+For individual command usage, run:
+  %s help <command>
+`
+
+func usage() {
+	command := os.Args[0]
+	var subcommands []string
+	for subcommand := range commands.CommandMap {
+		subcommands = append(subcommands, subcommand)
+	}
+	sort.Strings(subcommands)
+	fmt.Printf(usageMessageTemplate, command, strings.Join(subcommands, "\n  "), command)
+}
+
+func help() {
+	if len(os.Args) < 3 {
+		usage()
+		return
+	}
+	subcommand, ok := commands.CommandMap[os.Args[2]]
+	if !ok {
+		fmt.Printf("Unknown command %q\n", os.Args[2])
+		usage()
+		return
+	}
+	subcommand.Usage(os.Args[0])
+}
 
 func main() {
-	fmt.Println("Hello git !")
+	if len(os.Args) > 1 && os.Args[1] == "help" {
+		help()
+		return
+	}
+	cwd, err := os.Getwd()
+	if err != nil {
+		fmt.Printf("Unable to get the current working directory: %q\n", err)
+		return
+	}
+	repo, err := repository.NewGitRepo(cwd)
+	if err != nil {
+		fmt.Printf("%s must be run from within a git repo.\n", os.Args[0])
+		return
+	}
+	if len(os.Args) < 2 {
+		// default behavior
+		fmt.Println("Not implemented")
+		return
+	}
+	subcommand, ok := commands.CommandMap[os.Args[1]]
+	if !ok {
+		fmt.Printf("Unknown command: %q\n", os.Args[1])
+		usage()
+		return
+	}
+	if err := subcommand.Run(repo, os.Args[2:]); err != nil {
+		fmt.Println(err.Error())
+		os.Exit(1)
+	}
 }

notes 🔗

@@ -0,0 +1,47 @@
+echo "dnkqslnokqe" | git hash-object --stdin -w
+bca2068e9b221d47154d1e3eaebf8c9abd86951f
+
+git cat-file -p bca2068e9b221d47154d1e3eaebf8c9abd86951f
+dnkqslnokqe
+
+
+git update-ref refs/bug/HEAD bca2068e9b221d47154d1e3eaebf8c9abd86951f
+
+git show refs/bug/HEAD
+
+git push origin "refs/bug/*"
+
+git fetch origin "refs/bug/*:refs/bug/*"
+
+
+
+
+
+
+Bug operations:
+- create bug
+- change title
+- add comment
+- edit text comment
+- hide/unhide/delete comment
+- (?) reply to comment
+- add/remove [label, assignee, project, milestone, ...]
+
+
+G-Set (append only log)
+-----
+
+- create bug
+- change title
+- add/edit/hide/unhide/delete comment
+
+
+LWW-e-Set (add + remove, based on timestamp)
+---------
+
+- label
+- assignee
+- project
+- milestone
+
+

repository/git.go 🔗

@@ -0,0 +1,838 @@
+// Package repository contains helper methods for working with the Git repo.
+package repository
+
+import (
+	"bytes"
+	"crypto/sha1"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+const branchRefPrefix = "refs/heads/"
+
+// GitRepo represents an instance of a (local) git repository.
+type GitRepo struct {
+	Path string
+}
+
+// Run the given git command with the given I/O reader/writers, returning an error if it fails.
+func (repo *GitRepo) runGitCommandWithIO(stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
+	cmd := exec.Command("git", args...)
+	cmd.Dir = repo.Path
+	cmd.Stdin = stdin
+	cmd.Stdout = stdout
+	cmd.Stderr = stderr
+	return cmd.Run()
+}
+
+// Run the given git command and return its stdout, or an error if the command fails.
+func (repo *GitRepo) runGitCommandRaw(args ...string) (string, string, error) {
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
+	err := repo.runGitCommandWithIO(nil, &stdout, &stderr, args...)
+	return strings.TrimSpace(stdout.String()), strings.TrimSpace(stderr.String()), err
+}
+
+// Run the given git command and return its stdout, or an error if the command fails.
+func (repo *GitRepo) runGitCommand(args ...string) (string, error) {
+	stdout, stderr, err := repo.runGitCommandRaw(args...)
+	if err != nil {
+		if stderr == "" {
+			stderr = "Error running git command: " + strings.Join(args, " ")
+		}
+		err = fmt.Errorf(stderr)
+	}
+	return stdout, err
+}
+
+// Run the given git command using the same stdin, stdout, and stderr as the review tool.
+func (repo *GitRepo) runGitCommandInline(args ...string) error {
+	return repo.runGitCommandWithIO(os.Stdin, os.Stdout, os.Stderr, args...)
+}
+
+// NewGitRepo determines if the given working directory is inside of a git repository,
+// and returns the corresponding GitRepo instance if it is.
+func NewGitRepo(path string) (*GitRepo, error) {
+	repo := &GitRepo{Path: path}
+	_, _, err := repo.runGitCommandRaw("rev-parse")
+	if err == nil {
+		return repo, nil
+	}
+	if _, ok := err.(*exec.ExitError); ok {
+		return nil, err
+	}
+	return nil, err
+}
+
+// GetPath returns the path to the repo.
+func (repo *GitRepo) GetPath() string {
+	return repo.Path
+}
+
+// GetRepoStateHash returns a hash which embodies the entire current state of a repository.
+func (repo *GitRepo) GetRepoStateHash() (string, error) {
+	stateSummary, err := repo.runGitCommand("show-ref")
+	return fmt.Sprintf("%x", sha1.Sum([]byte(stateSummary))), err
+}
+
+// GetUserEmail returns the email address that the user has used to configure git.
+func (repo *GitRepo) GetUserEmail() (string, error) {
+	return repo.runGitCommand("config", "user.email")
+}
+
+// GetCoreEditor returns the name of the editor that the user has used to configure git.
+func (repo *GitRepo) GetCoreEditor() (string, error) {
+	return repo.runGitCommand("var", "GIT_EDITOR")
+}
+
+
+// PullRefs pull git refs from a remote
+func (repo *GitRepo) PullRefs(remote string, refPattern string) error {
+	refspec := fmt.Sprintf("%s:%s", refPattern, refPattern)
+
+	// The push is liable to fail if the user forgot to do a pull first, so
+	// we treat errors as user errors rather than fatal errors.
+	err := repo.runGitCommandInline("push", remote, refspec)
+	if err != nil {
+		return fmt.Errorf("failed to push to the remote '%s': %v", remote, err)
+	}
+	return nil
+}
+
+// PushRefs push git refs to a remote
+func (repo *GitRepo) PushRefs(remote string, refPattern string) error {
+	remoteNotesRefPattern := getRemoteNotesRef(remote, refPattern)
+	fetchRefSpec := fmt.Sprintf("+%s:%s", refPattern, remoteNotesRefPattern)
+	err := repo.runGitCommandInline("fetch", remote, fetchRefSpec)
+	if err != nil {
+		return err
+	}
+
+	return repo.mergeRemoteNotes(remote, notesRefPattern)
+}
+
+/*
+//
+//// GetSubmitStrategy returns the way in which a review is submitted
+//func (repo *GitRepo) GetSubmitStrategy() (string, error) {
+//	submitStrategy, _ := repo.runGitCommand("config", "appraise.submit")
+//	return submitStrategy, nil
+//}
+//
+//// HasUncommittedChanges returns true if there are local, uncommitted changes.
+//func (repo *GitRepo) HasUncommittedChanges() (bool, error) {
+//	out, err := repo.runGitCommand("status", "--porcelain")
+//	if err != nil {
+//		return false, err
+//	}
+//	if len(out) > 0 {
+//		return true, nil
+//	}
+//	return false, nil
+//}
+//
+//// VerifyCommit verifies that the supplied hash points to a known commit.
+//func (repo *GitRepo) VerifyCommit(hash string) error {
+//	out, err := repo.runGitCommand("cat-file", "-t", hash)
+//	if err != nil {
+//		return err
+//	}
+//	objectType := strings.TrimSpace(string(out))
+//	if objectType != "commit" {
+//		return fmt.Errorf("Hash %q points to a non-commit object of type %q", hash, objectType)
+//	}
+//	return nil
+//}
+//
+//// VerifyGitRef verifies that the supplied ref points to a known commit.
+//func (repo *GitRepo) VerifyGitRef(ref string) error {
+//	_, err := repo.runGitCommand("show-ref", "--verify", ref)
+//	return err
+//}
+//
+//// GetHeadRef returns the ref that is the current HEAD.
+//func (repo *GitRepo) GetHeadRef() (string, error) {
+//	return repo.runGitCommand("symbolic-ref", "HEAD")
+//}
+//
+//// GetCommitHash returns the hash of the commit pointed to by the given ref.
+//func (repo *GitRepo) GetCommitHash(ref string) (string, error) {
+//	return repo.runGitCommand("show", "-s", "--format=%H", ref)
+//}
+//
+//// ResolveRefCommit returns the commit pointed to by the given ref, which may be a remote ref.
+////
+//// This differs from GetCommitHash which only works on exact matches, in that it will try to
+//// intelligently handle the scenario of a ref not existing locally, but being known to exist
+//// in a remote repo.
+////
+//// This method should be used when a command may be performed by either the reviewer or the
+//// reviewee, while GetCommitHash should be used when the encompassing command should only be
+//// performed by the reviewee.
+//func (repo *GitRepo) ResolveRefCommit(ref string) (string, error) {
+//	if err := repo.VerifyGitRef(ref); err == nil {
+//		return repo.GetCommitHash(ref)
+//	}
+//	if strings.HasPrefix(ref, "refs/heads/") {
+//		// The ref is a branch. Check if it exists in exactly one remote
+//		pattern := strings.Replace(ref, "refs/heads", "**", 1)
+//		matchingOutput, err := repo.runGitCommand("for-each-ref", "--format=%(refname)", pattern)
+//		if err != nil {
+//			return "", err
+//		}
+//		matchingRefs := strings.Split(matchingOutput, "\n")
+//		if len(matchingRefs) == 1 && matchingRefs[0] != "" {
+//			// There is exactly one match
+//			return repo.GetCommitHash(matchingRefs[0])
+//		}
+//		return "", fmt.Errorf("Unable to find a git ref matching the pattern %q", pattern)
+//	}
+//	return "", fmt.Errorf("Unknown git ref %q", ref)
+//}
+//
+//// GetCommitMessage returns the message stored in the commit pointed to by the given ref.
+//func (repo *GitRepo) GetCommitMessage(ref string) (string, error) {
+//	return repo.runGitCommand("show", "-s", "--format=%B", ref)
+//}
+//
+//// GetCommitTime returns the commit time of the commit pointed to by the given ref.
+//func (repo *GitRepo) GetCommitTime(ref string) (string, error) {
+//	return repo.runGitCommand("show", "-s", "--format=%ct", ref)
+//}
+//
+//// GetLastParent returns the last parent of the given commit (as ordered by git).
+//func (repo *GitRepo) GetLastParent(ref string) (string, error) {
+//	return repo.runGitCommand("rev-list", "--skip", "1", "-n", "1", ref)
+//}
+//
+//// GetCommitDetails returns the details of a commit's metadata.
+//func (repo GitRepo) GetCommitDetails(ref string) (*CommitDetails, error) {
+//	var err error
+//	show := func(formatString string) (result string) {
+//		if err != nil {
+//			return ""
+//		}
+//		result, err = repo.runGitCommand("show", "-s", ref, fmt.Sprintf("--format=tformat:%s", formatString))
+//		return result
+//	}
+//
+//	jsonFormatString := "{\"tree\":\"%T\", \"time\": \"%at\"}"
+//	detailsJSON := show(jsonFormatString)
+//	if err != nil {
+//		return nil, err
+//	}
+//	var details CommitDetails
+//	err = json.Unmarshal([]byte(detailsJSON), &details)
+//	if err != nil {
+//		return nil, err
+//	}
+//	details.Author = show("%an")
+//	details.AuthorEmail = show("%ae")
+//	details.Summary = show("%s")
+//	parentsString := show("%P")
+//	details.Parents = strings.Split(parentsString, " ")
+//	if err != nil {
+//		return nil, err
+//	}
+//	return &details, nil
+//}
+//
+//// MergeBase determines if the first commit that is an ancestor of the two arguments.
+//func (repo *GitRepo) MergeBase(a, b string) (string, error) {
+//	return repo.runGitCommand("merge-base", a, b)
+//}
+//
+//// IsAncestor determines if the first argument points to a commit that is an ancestor of the second.
+//func (repo *GitRepo) IsAncestor(ancestor, descendant string) (bool, error) {
+//	_, _, err := repo.runGitCommandRaw("merge-base", "--is-ancestor", ancestor, descendant)
+//	if err == nil {
+//		return true, nil
+//	}
+//	if _, ok := err.(*exec.ExitError); ok {
+//		return false, nil
+//	}
+//	return false, fmt.Errorf("Error while trying to determine commit ancestry: %v", err)
+//}
+//
+//// Diff computes the diff between two given commits.
+//func (repo *GitRepo) Diff(left, right string, diffArgs ...string) (string, error) {
+//	args := []string{"diff"}
+//	args = append(args, diffArgs...)
+//	args = append(args, fmt.Sprintf("%s..%s", left, right))
+//	return repo.runGitCommand(args...)
+//}
+//
+//// Show returns the contents of the given file at the given commit.
+//func (repo *GitRepo) Show(commit, path string) (string, error) {
+//	return repo.runGitCommand("show", fmt.Sprintf("%s:%s", commit, path))
+//}
+//
+//// SwitchToRef changes the currently-checked-out ref.
+//func (repo *GitRepo) SwitchToRef(ref string) error {
+//	// If the ref starts with "refs/heads/", then we have to trim that prefix,
+//	// or else we will wind up in a detached HEAD state.
+//	if strings.HasPrefix(ref, branchRefPrefix) {
+//		ref = ref[len(branchRefPrefix):]
+//	}
+//	_, err := repo.runGitCommand("checkout", ref)
+//	return err
+//}
+//
+//// mergeArchives merges two archive refs.
+//func (repo *GitRepo) mergeArchives(archive, remoteArchive string) error {
+//	remoteHash, err := repo.GetCommitHash(remoteArchive)
+//	if err != nil {
+//		return err
+//	}
+//	if remoteHash == "" {
+//		// The remote archive does not exist, so we have nothing to do
+//		return nil
+//	}
+//
+//	archiveHash, err := repo.GetCommitHash(archive)
+//	if err != nil {
+//		return err
+//	}
+//	if archiveHash == "" {
+//		// The local archive does not exist, so we merely need to set it
+//		_, err := repo.runGitCommand("update-ref", archive, remoteHash)
+//		return err
+//	}
+//
+//	isAncestor, err := repo.IsAncestor(archiveHash, remoteHash)
+//	if err != nil {
+//		return err
+//	}
+//	if isAncestor {
+//		// The archive can simply be fast-forwarded
+//		_, err := repo.runGitCommand("update-ref", archive, remoteHash, archiveHash)
+//		return err
+//	}
+//
+//	// Create a merge commit of the two archives
+//	refDetails, err := repo.GetCommitDetails(remoteArchive)
+//	if err != nil {
+//		return err
+//	}
+//	newArchiveHash, err := repo.runGitCommand("commit-tree", "-p", remoteHash, "-p", archiveHash, "-m", "Merge local and remote archives", refDetails.Tree)
+//	if err != nil {
+//		return err
+//	}
+//	newArchiveHash = strings.TrimSpace(newArchiveHash)
+//	_, err = repo.runGitCommand("update-ref", archive, newArchiveHash, archiveHash)
+//	return err
+//}
+//
+//// ArchiveRef adds the current commit pointed to by the 'ref' argument
+//// under the ref specified in the 'archive' argument.
+////
+//// Both the 'ref' and 'archive' arguments are expected to be the fully
+//// qualified names of git refs (e.g. 'refs/heads/my-change' or
+//// 'refs/devtools/archives/reviews').
+////
+//// If the ref pointed to by the 'archive' argument does not exist
+//// yet, then it will be created.
+//func (repo *GitRepo) ArchiveRef(ref, archive string) error {
+//	refHash, err := repo.GetCommitHash(ref)
+//	if err != nil {
+//		return err
+//	}
+//	refDetails, err := repo.GetCommitDetails(ref)
+//	if err != nil {
+//		return err
+//	}
+//
+//	commitTreeArgs := []string{"commit-tree"}
+//	archiveHash, err := repo.GetCommitHash(archive)
+//	if err != nil {
+//		archiveHash = ""
+//	} else {
+//		commitTreeArgs = append(commitTreeArgs, "-p", archiveHash)
+//	}
+//	commitTreeArgs = append(commitTreeArgs, "-p", refHash, "-m", fmt.Sprintf("Archive %s", refHash), refDetails.Tree)
+//	newArchiveHash, err := repo.runGitCommand(commitTreeArgs...)
+//	if err != nil {
+//		return err
+//	}
+//	newArchiveHash = strings.TrimSpace(newArchiveHash)
+//	updateRefArgs := []string{"update-ref", archive, newArchiveHash}
+//	if archiveHash != "" {
+//		updateRefArgs = append(updateRefArgs, archiveHash)
+//	}
+//	_, err = repo.runGitCommand(updateRefArgs...)
+//	return err
+//}
+//
+//// MergeRef merges the given ref into the current one.
+////
+//// The ref argument is the ref to merge, and fastForward indicates that the
+//// current ref should only move forward, as opposed to creating a bubble merge.
+//// The messages argument(s) provide text that should be included in the default
+//// merge commit message (separated by blank lines).
+//func (repo *GitRepo) MergeRef(ref string, fastForward bool, messages ...string) error {
+//	args := []string{"merge"}
+//	if fastForward {
+//		args = append(args, "--ff", "--ff-only")
+//	} else {
+//		args = append(args, "--no-ff")
+//	}
+//	if len(messages) > 0 {
+//		commitMessage := strings.Join(messages, "\n\n")
+//		args = append(args, "-e", "-m", commitMessage)
+//	}
+//	args = append(args, ref)
+//	return repo.runGitCommandInline(args...)
+//}
+//
+//// RebaseRef rebases the current ref onto the given one.
+//func (repo *GitRepo) RebaseRef(ref string) error {
+//	return repo.runGitCommandInline("rebase", "-i", ref)
+//}
+//
+//// ListCommits returns the list of commits reachable from the given ref.
+////
+//// The generated list is in chronological order (with the oldest commit first).
+////
+//// If the specified ref does not exist, then this method returns an empty result.
+//func (repo *GitRepo) ListCommits(ref string) []string {
+//	var stdout bytes.Buffer
+//	var stderr bytes.Buffer
+//	if err := repo.runGitCommandWithIO(nil, &stdout, &stderr, "rev-list", "--reverse", ref); err != nil {
+//		return nil
+//	}
+//
+//	byteLines := bytes.Split(stdout.Bytes(), []byte("\n"))
+//	var commits []string
+//	for _, byteLine := range byteLines {
+//		commits = append(commits, string(byteLine))
+//	}
+//	return commits
+//}
+//
+//// ListCommitsBetween returns the list of commits between the two given revisions.
+////
+//// The "from" parameter is the starting point (exclusive), and the "to"
+//// parameter is the ending point (inclusive).
+////
+//// The "from" commit does not need to be an ancestor of the "to" commit. If it
+//// is not, then the merge base of the two is used as the starting point.
+//// Admittedly, this makes calling these the "between" commits is a bit of a
+//// misnomer, but it also makes the method easier to use when you want to
+//// generate the list of changes in a feature branch, as it eliminates the need
+//// to explicitly calculate the merge base. This also makes the semantics of the
+//// method compatible with git's built-in "rev-list" command.
+////
+//// The generated list is in chronological order (with the oldest commit first).
+//func (repo *GitRepo) ListCommitsBetween(from, to string) ([]string, error) {
+//	out, err := repo.runGitCommand("rev-list", "--reverse", from+".."+to)
+//	if err != nil {
+//		return nil, err
+//	}
+//	if out == "" {
+//		return nil, nil
+//	}
+//	return strings.Split(out, "\n"), nil
+//}
+//
+//// GetNotes uses the "git" command-line tool to read the notes from the given ref for a given revision.
+//func (repo *GitRepo) GetNotes(notesRef, revision string) []Note {
+//	var notes []Note
+//	rawNotes, err := repo.runGitCommand("notes", "--ref", notesRef, "show", revision)
+//	if err != nil {
+//		// We just assume that this means there are no notes
+//		return nil
+//	}
+//	for _, line := range strings.Split(rawNotes, "\n") {
+//		notes = append(notes, Note([]byte(line)))
+//	}
+//	return notes
+//}
+//
+//func stringsReader(s []*string) io.Reader {
+//	var subReaders []io.Reader
+//	for _, strPtr := range s {
+//		subReader := strings.NewReader(*strPtr)
+//		subReaders = append(subReaders, subReader, strings.NewReader("\n"))
+//	}
+//	return io.MultiReader(subReaders...)
+//}
+//
+//// splitBatchCheckOutput parses the output of a 'git cat-file --batch-check=...' command.
+////
+//// The output is expected to be formatted as a series of entries, with each
+//// entry consisting of:
+//// 1. The SHA1 hash of the git object being output, followed by a space.
+//// 2. The git "type" of the object (commit, blob, tree, missing, etc), followed by a newline.
+////
+//// To generate this format, make sure that the 'git cat-file' command includes
+//// the argument '--batch-check=%(objectname) %(objecttype)'.
+////
+//// The return value is a map from object hash to a boolean indicating if that object is a commit.
+//func splitBatchCheckOutput(out *bytes.Buffer) (map[string]bool, error) {
+//	isCommit := make(map[string]bool)
+//	reader := bufio.NewReader(out)
+//	for {
+//		nameLine, err := reader.ReadString(byte(' '))
+//		if err == io.EOF {
+//			return isCommit, nil
+//		}
+//		if err != nil {
+//			return nil, fmt.Errorf("Failure while reading the next object name: %v", err)
+//		}
+//		nameLine = strings.TrimSuffix(nameLine, " ")
+//		typeLine, err := reader.ReadString(byte('\n'))
+//		if err != nil && err != io.EOF {
+//			return nil, fmt.Errorf("Failure while reading the next object type: %q - %v", nameLine, err)
+//		}
+//		typeLine = strings.TrimSuffix(typeLine, "\n")
+//		if typeLine == "commit" {
+//			isCommit[nameLine] = true
+//		}
+//	}
+//}
+//
+//// splitBatchCatFileOutput parses the output of a 'git cat-file --batch=...' command.
+////
+//// The output is expected to be formatted as a series of entries, with each
+//// entry consisting of:
+//// 1. The SHA1 hash of the git object being output, followed by a newline.
+//// 2. The size of the object's contents in bytes, followed by a newline.
+//// 3. The objects contents.
+////
+//// To generate this format, make sure that the 'git cat-file' command includes
+//// the argument '--batch=%(objectname)\n%(objectsize)'.
+//func splitBatchCatFileOutput(out *bytes.Buffer) (map[string][]byte, error) {
+//	contentsMap := make(map[string][]byte)
+//	reader := bufio.NewReader(out)
+//	for {
+//		nameLine, err := reader.ReadString(byte('\n'))
+//		if strings.HasSuffix(nameLine, "\n") {
+//			nameLine = strings.TrimSuffix(nameLine, "\n")
+//		}
+//		if err == io.EOF {
+//			return contentsMap, nil
+//		}
+//		if err != nil {
+//			return nil, fmt.Errorf("Failure while reading the next object name: %v", err)
+//		}
+//		sizeLine, err := reader.ReadString(byte('\n'))
+//		if strings.HasSuffix(sizeLine, "\n") {
+//			sizeLine = strings.TrimSuffix(sizeLine, "\n")
+//		}
+//		if err != nil {
+//			return nil, fmt.Errorf("Failure while reading the next object size: %q - %v", nameLine, err)
+//		}
+//		size, err := strconv.Atoi(sizeLine)
+//		if err != nil {
+//			return nil, fmt.Errorf("Failure while parsing the next object size: %q - %v", nameLine, err)
+//		}
+//		contentBytes := make([]byte, size, size)
+//		readDest := contentBytes
+//		len := 0
+//		err = nil
+//		for err == nil && len < size {
+//			nextLen := 0
+//			nextLen, err = reader.Read(readDest)
+//			len += nextLen
+//			readDest = contentBytes[len:]
+//		}
+//		contentsMap[nameLine] = contentBytes
+//		if err == io.EOF {
+//			return contentsMap, nil
+//		}
+//		if err != nil {
+//			return nil, err
+//		}
+//		for bs, err := reader.Peek(1); err == nil && bs[0] == byte('\n'); bs, err = reader.Peek(1) {
+//			reader.ReadByte()
+//		}
+//	}
+//}
+//
+//// notesMapping represents the association between a git object and the notes for that object.
+//type notesMapping struct {
+//	ObjectHash *string
+//	NotesHash  *string
+//}
+//
+//// notesOverview represents a high-level overview of all the notes under a single notes ref.
+//type notesOverview struct {
+//	NotesMappings      []*notesMapping
+//	ObjectHashesReader io.Reader
+//	NotesHashesReader  io.Reader
+//}
+//
+//// notesOverview returns an overview of the git notes stored under the given ref.
+//func (repo *GitRepo) notesOverview(notesRef string) (*notesOverview, error) {
+//	var stdout bytes.Buffer
+//	var stderr bytes.Buffer
+//	if err := repo.runGitCommandWithIO(nil, &stdout, &stderr, "notes", "--ref", notesRef, "list"); err != nil {
+//		return nil, err
+//	}
+//
+//	var notesMappings []*notesMapping
+//	var objHashes []*string
+//	var notesHashes []*string
+//	outScanner := bufio.NewScanner(&stdout)
+//	for outScanner.Scan() {
+//		line := outScanner.Text()
+//		lineParts := strings.Split(line, " ")
+//		if len(lineParts) != 2 {
+//			return nil, fmt.Errorf("Malformed output line from 'git-notes list': %q", line)
+//		}
+//		objHash := &lineParts[1]
+//		notesHash := &lineParts[0]
+//		notesMappings = append(notesMappings, &notesMapping{
+//			ObjectHash: objHash,
+//			NotesHash:  notesHash,
+//		})
+//		objHashes = append(objHashes, objHash)
+//		notesHashes = append(notesHashes, notesHash)
+//	}
+//	err := outScanner.Err()
+//	if err != nil && err != io.EOF {
+//		return nil, fmt.Errorf("Failure parsing the output of 'git-notes list': %v", err)
+//	}
+//	return &notesOverview{
+//		NotesMappings:      notesMappings,
+//		ObjectHashesReader: stringsReader(objHashes),
+//		NotesHashesReader:  stringsReader(notesHashes),
+//	}, nil
+//}
+//
+//// getIsCommitMap returns a mapping of all the annotated objects that are commits.
+//func (overview *notesOverview) getIsCommitMap(repo *GitRepo) (map[string]bool, error) {
+//	var stdout bytes.Buffer
+//	var stderr bytes.Buffer
+//	if err := repo.runGitCommandWithIO(overview.ObjectHashesReader, &stdout, &stderr, "cat-file", "--batch-check=%(objectname) %(objecttype)"); err != nil {
+//		return nil, fmt.Errorf("Failure performing a batch file check: %v", err)
+//	}
+//	isCommit, err := splitBatchCheckOutput(&stdout)
+//	if err != nil {
+//		return nil, fmt.Errorf("Failure parsing the output of a batch file check: %v", err)
+//	}
+//	return isCommit, nil
+//}
+//
+//// getNoteContentsMap returns a mapping from all the notes hashes to their contents.
+//func (overview *notesOverview) getNoteContentsMap(repo *GitRepo) (map[string][]byte, error) {
+//	var stdout bytes.Buffer
+//	var stderr bytes.Buffer
+//	if err := repo.runGitCommandWithIO(overview.NotesHashesReader, &stdout, &stderr, "cat-file", "--batch=%(objectname)\n%(objectsize)"); err != nil {
+//		return nil, fmt.Errorf("Failure performing a batch file read: %v", err)
+//	}
+//	noteContentsMap, err := splitBatchCatFileOutput(&stdout)
+//	if err != nil {
+//		return nil, fmt.Errorf("Failure parsing the output of a batch file read: %v", err)
+//	}
+//	return noteContentsMap, nil
+//}
+//
+//// GetAllNotes reads the contents of the notes under the given ref for every commit.
+////
+//// The returned value is a mapping from commit hash to the list of notes for that commit.
+////
+//// This is the batch version of the corresponding GetNotes(...) method.
+//func (repo *GitRepo) GetAllNotes(notesRef string) (map[string][]Note, error) {
+//	// This code is unfortunately quite complicated, but it needs to be so.
+//	//
+//	// Conceptually, this is equivalent to:
+//	//   result := make(map[string][]Note)
+//	//   for _, commit := range repo.ListNotedRevisions(notesRef) {
+//	//     result[commit] = repo.GetNotes(notesRef, commit)
+//	//   }
+//	//   return result, nil
+//	//
+//	// However, that logic would require separate executions of the 'git'
+//	// command for every annotated commit. For a repo with 10s of thousands
+//	// of reviews, that would mean calling Cmd.Run(...) 10s of thousands of
+//	// times. That, in turn, would take so long that the tool would be unusable.
+//	//
+//	// This method avoids that by taking advantage of the 'git cat-file --batch="..."'
+//	// command. That allows us to use a single invocation of Cmd.Run(...) to
+//	// inspect multiple git objects at once.
+//	//
+//	// As such, regardless of the number of reviews in a repo, we can get all
+//	// of the notes using a total of three invocations of Cmd.Run(...):
+//	//  1. One to list all the annotated objects (and their notes hash)
+//	//  2. A second one to filter out all of the annotated objects that are not commits.
+//	//  3. A final one to get the contents of all of the notes blobs.
+//	overview, err := repo.notesOverview(notesRef)
+//	if err != nil {
+//		return nil, err
+//	}
+//	isCommit, err := overview.getIsCommitMap(repo)
+//	if err != nil {
+//		return nil, fmt.Errorf("Failure building the set of commit objects: %v", err)
+//	}
+//	noteContentsMap, err := overview.getNoteContentsMap(repo)
+//	if err != nil {
+//		return nil, fmt.Errorf("Failure building the mapping from notes hash to contents: %v", err)
+//	}
+//	commitNotesMap := make(map[string][]Note)
+//	for _, notesMapping := range overview.NotesMappings {
+//		if !isCommit[*notesMapping.ObjectHash] {
+//			continue
+//		}
+//		noteBytes := noteContentsMap[*notesMapping.NotesHash]
+//		byteSlices := bytes.Split(noteBytes, []byte("\n"))
+//		var notes []Note
+//		for _, slice := range byteSlices {
+//			notes = append(notes, Note(slice))
+//		}
+//		commitNotesMap[*notesMapping.ObjectHash] = notes
+//	}
+//
+//	return commitNotesMap, nil
+//}
+//
+//// AppendNote appends a note to a revision under the given ref.
+//func (repo *GitRepo) AppendNote(notesRef, revision string, note Note) error {
+//	_, err := repo.runGitCommand("notes", "--ref", notesRef, "append", "-m", string(note), revision)
+//	return err
+//}
+//
+//// ListNotedRevisions returns the collection of revisions that are annotated by notes in the given ref.
+//func (repo *GitRepo) ListNotedRevisions(notesRef string) []string {
+//	var revisions []string
+//	notesListOut, err := repo.runGitCommand("notes", "--ref", notesRef, "list")
+//	if err != nil {
+//		return nil
+//	}
+//	notesList := strings.Split(notesListOut, "\n")
+//	for _, notePair := range notesList {
+//		noteParts := strings.SplitN(notePair, " ", 2)
+//		if len(noteParts) == 2 {
+//			objHash := noteParts[1]
+//			objType, err := repo.runGitCommand("cat-file", "-t", objHash)
+//			// If a note points to an object that we do not know about (yet), then err will not
+//			// be nil. We can safely just ignore those notes.
+//			if err == nil && objType == "commit" {
+//				revisions = append(revisions, objHash)
+//			}
+//		}
+//	}
+//	return revisions
+//}
+//
+//// PushNotes pushes git notes to a remote repo.
+//func (repo *GitRepo) PushNotes(remote, notesRefPattern string) error {
+//	refspec := fmt.Sprintf("%s:%s", notesRefPattern, notesRefPattern)
+//
+//	// The push is liable to fail if the user forgot to do a pull first, so
+//	// we treat errors as user errors rather than fatal errors.
+//	err := repo.runGitCommandInline("push", remote, refspec)
+//	if err != nil {
+//		return fmt.Errorf("Failed to push to the remote '%s': %v", remote, err)
+//	}
+//	return nil
+//}
+//
+//// PushNotesAndArchive pushes the given notes and archive refs to a remote repo.
+//func (repo *GitRepo) PushNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error {
+//	notesRefspec := fmt.Sprintf("%s:%s", notesRefPattern, notesRefPattern)
+//	archiveRefspec := fmt.Sprintf("%s:%s", archiveRefPattern, archiveRefPattern)
+//	err := repo.runGitCommandInline("push", remote, notesRefspec, archiveRefspec)
+//	if err != nil {
+//		return fmt.Errorf("Failed to push the local archive to the remote '%s': %v", remote, err)
+//	}
+//	return nil
+//}
+//
+//func getRemoteNotesRef(remote, localNotesRef string) string {
+//	relativeNotesRef := strings.TrimPrefix(localNotesRef, "refs/notes/")
+//	return "refs/notes/" + remote + "/" + relativeNotesRef
+//}
+//
+//func (repo *GitRepo) mergeRemoteNotes(remote, notesRefPattern string) error {
+//	remoteRefs, err := repo.runGitCommand("ls-remote", remote, notesRefPattern)
+//	if err != nil {
+//		return err
+//	}
+//	for _, line := range strings.Split(remoteRefs, "\n") {
+//		lineParts := strings.Split(line, "\t")
+//		if len(lineParts) == 2 {
+//			ref := lineParts[1]
+//			remoteRef := getRemoteNotesRef(remote, ref)
+//			_, err := repo.runGitCommand("notes", "--ref", ref, "merge", remoteRef, "-s", "cat_sort_uniq")
+//			if err != nil {
+//				return err
+//			}
+//		}
+//	}
+//	return nil
+//}
+//
+//// PullNotes fetches the contents of the given notes ref from a remote repo,
+//// and then merges them with the corresponding local notes using the
+//// "cat_sort_uniq" strategy.
+//func (repo *GitRepo) PullNotes(remote, notesRefPattern string) error {
+//	remoteNotesRefPattern := getRemoteNotesRef(remote, notesRefPattern)
+//	fetchRefSpec := fmt.Sprintf("+%s:%s", notesRefPattern, remoteNotesRefPattern)
+//	err := repo.runGitCommandInline("fetch", remote, fetchRefSpec)
+//	if err != nil {
+//		return err
+//	}
+//
+//	return repo.mergeRemoteNotes(remote, notesRefPattern)
+//}
+//
+//func getRemoteArchiveRef(remote, archiveRefPattern string) string {
+//	relativeArchiveRef := strings.TrimPrefix(archiveRefPattern, "refs/devtools/archives/")
+//	return "refs/devtools/remoteArchives/" + remote + "/" + relativeArchiveRef
+//}
+//
+//func (repo *GitRepo) mergeRemoteArchives(remote, archiveRefPattern string) error {
+//	remoteRefs, err := repo.runGitCommand("ls-remote", remote, archiveRefPattern)
+//	if err != nil {
+//		return err
+//	}
+//	for _, line := range strings.Split(remoteRefs, "\n") {
+//		lineParts := strings.Split(line, "\t")
+//		if len(lineParts) == 2 {
+//			ref := lineParts[1]
+//			remoteRef := getRemoteArchiveRef(remote, ref)
+//			if err := repo.mergeArchives(ref, remoteRef); err != nil {
+//				return err
+//			}
+//		}
+//	}
+//	return nil
+//}
+//
+//// PullNotesAndArchive fetches the contents of the notes and archives refs from
+//// a remote repo, and merges them with the corresponding local refs.
+////
+//// For notes refs, we assume that every note can be automatically merged using
+//// the 'cat_sort_uniq' strategy (the git-appraise schemas fit that requirement),
+//// so we automatically merge the remote notes into the local notes.
+////
+//// For "archive" refs, they are expected to be used solely for maintaining
+//// reachability of commits that are part of the history of any reviews,
+//// so we do not maintain any consistency with their tree objects. Instead,
+//// we merely ensure that their history graph includes every commit that we
+//// intend to keep.
+//func (repo *GitRepo) PullNotesAndArchive(remote, notesRefPattern, archiveRefPattern string) error {
+//	remoteArchiveRef := getRemoteArchiveRef(remote, archiveRefPattern)
+//	archiveFetchRefSpec := fmt.Sprintf("+%s:%s", archiveRefPattern, remoteArchiveRef)
+//
+//	remoteNotesRefPattern := getRemoteNotesRef(remote, notesRefPattern)
+//	notesFetchRefSpec := fmt.Sprintf("+%s:%s", notesRefPattern, remoteNotesRefPattern)
+//
+//	err := repo.runGitCommandInline("fetch", remote, notesFetchRefSpec, archiveFetchRefSpec)
+//	if err != nil {
+//		return err
+//	}
+//
+//	if err := repo.mergeRemoteNotes(remote, notesRefPattern); err != nil {
+//		return err
+//	}
+//	if err := repo.mergeRemoteArchives(remote, archiveRefPattern); err != nil {
+//		return err
+//	}
+//	return nil
+//}
+*/

repository/mock_repo.go 🔗

@@ -0,0 +1,33 @@
+package repository
+
+import (
+	"crypto/sha1"
+	"encoding/json"
+	"fmt"
+)
+
+// mockRepoForTest defines an instance of Repo that can be used for testing.
+type mockRepoForTest struct {}
+
+// GetPath returns the path to the repo.
+func (r *mockRepoForTest) GetPath() string { return "~/mockRepo/" }
+
+// GetRepoStateHash returns a hash which embodies the entire current state of a repository.
+func (r *mockRepoForTest) GetRepoStateHash() (string, error) {
+	repoJSON, err := json.Marshal(r)
+	if err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("%x", sha1.Sum([]byte(repoJSON))), nil
+}
+
+// GetUserEmail returns the email address that the user has used to configure git.
+func (r *mockRepoForTest) GetUserEmail() (string, error) { return "user@example.com", nil }
+
+// GetCoreEditor returns the name of the editor that the user has used to configure git.
+func (r *mockRepoForTest) GetCoreEditor() (string, error) { return "vi", nil }
+
+// PushRefs push git refs to a remote
+func (r *mockRepoForTest) PushRefs(remote string, refPattern string) error {
+	return nil
+}

repository/repo.go 🔗

@@ -0,0 +1,20 @@
+// Package repository contains helper methods for working with a Git repo.
+package repository
+
+// Repo represents a source code repository.
+type Repo interface {
+	// GetPath returns the path to the repo.
+	GetPath() string
+
+	// GetUserEmail returns the email address that the user has used to configure git.
+	GetUserEmail() (string, error)
+
+	// GetCoreEditor returns the name of the editor that the user has used to configure git.
+	GetCoreEditor() (string, error)
+
+	// PullRefs pull git refs from a remote
+	PullRefs(remote string, refPattern string) error
+
+	// PushRefs push git refs to a remote
+	PushRefs(remote string, refPattern string) error
+}