diff --git a/ROADMAP.md b/ROADMAP.md index 0d4877a..bc39a47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,6 @@ to accept help from people asking to help. * Fast-forward perms on branches (so they can be deleted) * Ammending commits. * Figure out commit range syntax, use that everywhere. -* Support short hash names * Ability to specify a pgp key manually, even if it's not in the project. * Ability to require _any_ signature on a commit, even if it's not in the config. diff --git a/repo.go b/repo.go index 1d3d4bf..620bc2e 100644 --- a/repo.go +++ b/repo.go @@ -3,11 +3,13 @@ package dehub import ( "bytes" + "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" + "strings" "dehub.dev/src/dehub.git/fs" @@ -354,21 +356,6 @@ func (r *Repo) GetGitCommit(h plumbing.Hash) (gc GitCommit, err error) { 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 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 -} - // ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An // example of when this can happen is if the repo was just initialized and has // no commits, or if an orphan branch is checked out. @@ -436,10 +423,85 @@ func (r *Repo) GetGitCommitRange(start, end plumbing.Hash) ([]GitCommit, error) return commits, nil } +var ( + hashLen = len(plumbing.ZeroHash) + hashStrLen = len(plumbing.ZeroHash.String()) + errNotHex = errors.New("not a valid hex string") +) + +func (r *Repo) findCommitByShortHash(hashStr string) (plumbing.Hash, error) { + paddedHashStr := hashStr + if len(hashStr)%2 > 0 { + paddedHashStr += "0" + } + + if hashB, err := hex.DecodeString(paddedHashStr); err != nil { + return plumbing.ZeroHash, errNotHex + } else if len(hashStr) == hashStrLen { + var hash plumbing.Hash + copy(hash[:], hashB) + return hash, nil + } else if len(hashStr) < 2 { + return plumbing.ZeroHash, errors.New("hash string must be 2 characters long or more") + } + + for i := 2; i < hashStrLen; i++ { + hashPrefix, hashTail := hashStr[:i], hashStr[i:] + path := filepath.Join("objects", hashPrefix) + fileInfos, err := r.GitDirFS.ReadDir(path) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("listing files in %q: %w", path, err) + } + + var matchedHash plumbing.Hash + for _, fileInfo := range fileInfos { + objFileName := fileInfo.Name() + if !strings.HasPrefix(objFileName, hashTail) { + continue + } + + objHash := plumbing.NewHash(hashPrefix + objFileName) + obj, err := r.GitRepo.Storer.EncodedObject(plumbing.AnyObject, objHash) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("reading object %q off disk: %w", objHash, err) + } else if obj.Type() != plumbing.CommitObject { + continue + + } else if matchedHash == plumbing.ZeroHash { + matchedHash = objHash + continue + } + + return plumbing.ZeroHash, fmt.Errorf("both %q and %q match", matchedHash, objHash) + } + + if matchedHash != plumbing.ZeroHash { + return matchedHash, nil + } + } + + return plumbing.ZeroHash, errors.New("failed to find a commit object with a matching prefix") +} + func (r *Repo) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) { if rev == plumbing.Revision(plumbing.ZeroHash.String()) { return plumbing.ZeroHash, nil } + + { + // pretend the revision is a short hash until proven otherwise + shortHash := string(rev) + hash, err := r.findCommitByShortHash(shortHash) + if errors.Is(err, errNotHex) { + // ok, continue + } else if err != nil { + return plumbing.ZeroHash, fmt.Errorf("resolving as short hash: %w", err) + } else { + // guess it _is_ a short hash, knew it! + return hash, nil + } + } + h, err := r.GitRepo.ResolveRevision(rev) if err != nil { return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err) @@ -447,6 +509,20 @@ func (r *Repo) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) { return *h, nil } +// GetGitRevision resolves the revision and returns the GitCommit it references. +func (r *Repo) GetGitRevision(rev plumbing.Revision) (GitCommit, error) { + hash, err := r.resolveRev(rev) + if err != nil { + return GitCommit{}, err + } + + gc, err := r.GetGitCommit(hash) + if err != nil { + return GitCommit{}, fmt.Errorf("getting commit %q: %w", hash, err) + } + return gc, 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) { diff --git a/repo_test.go b/repo_test.go index 55b49d1..6044795 100644 --- a/repo_test.go +++ b/repo_test.go @@ -309,3 +309,22 @@ func TestThisRepoStillVerifies(t *testing.T) { t.Fatal(err) } } + +func TestShortHashResolving(t *testing.T) { + // TODO ideally this test would test the conflicting hashes are noticed, but + // that's hard... + h := newHarness(t) + hash := h.changeCommit("first commit", h.cfg.Accounts[0].ID, h.sig).GitCommit.Hash + hashStr := hash.String() + t.Log(hashStr) + + for i := 2; i < len(hashStr); i++ { + gotCommit, err := h.repo.GetGitRevision(plumbing.Revision(hashStr[:i])) + if err != nil { + t.Fatalf("resolving %q: %v", hashStr[:i], err) + } else if gotCommit.GitCommit.Hash != hash { + t.Fatalf("expected hash %q but got %q", + gotCommit.GitCommit.Hash, hash) + } + } +}