1034 lines
31 KiB
Go
1034 lines
31 KiB
Go
package hop
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultGitBinary = "git"
|
|
defaultCommitName = "Hop"
|
|
defaultCommitMail = "hop@localhost"
|
|
hiddenRefPrefix = "refs/hop/"
|
|
)
|
|
|
|
// GitIdentity is the identity written to synthetic commits made by Hop.
|
|
type GitIdentity struct {
|
|
Name string
|
|
Email string
|
|
}
|
|
|
|
// GitStore locates repositories and supplies the Git executable and synthetic
|
|
// commit identity used by them. Its zero value is ready to use.
|
|
type GitStore struct {
|
|
Binary string
|
|
Identity GitIdentity
|
|
Now func() time.Time
|
|
}
|
|
|
|
// Repository is a non-bare Git worktree. Hop uses the repository's object
|
|
// database as storage, but never needs to stage data in the user's index.
|
|
type Repository struct {
|
|
root string
|
|
gitDir string
|
|
commonGitDir string
|
|
store *GitStore
|
|
}
|
|
|
|
// GitError reports a failed Git invocation without exposing its environment.
|
|
type GitError struct {
|
|
Args []string
|
|
ExitCode int
|
|
Stdout string
|
|
Stderr string
|
|
Err error
|
|
}
|
|
|
|
func (e *GitError) Error() string {
|
|
message := strings.TrimSpace(e.Stderr)
|
|
if message == "" {
|
|
message = strings.TrimSpace(e.Stdout)
|
|
}
|
|
if message == "" {
|
|
message = e.Err.Error()
|
|
}
|
|
return fmt.Sprintf("git %s: %s", strings.Join(e.Args, " "), message)
|
|
}
|
|
|
|
func (e *GitError) Unwrap() error { return e.Err }
|
|
|
|
// CommitOptions controls a synthetic commit. Empty identities and timestamps
|
|
// use the GitStore defaults; user Git identity configuration is never used.
|
|
type CommitOptions struct {
|
|
Message string
|
|
Parents []string
|
|
Author GitIdentity
|
|
Committer GitIdentity
|
|
AuthorTime time.Time
|
|
CommitterTime time.Time
|
|
}
|
|
|
|
// SnapshotOptions controls a workspace snapshot commit.
|
|
type SnapshotOptions struct {
|
|
Message string
|
|
Commit CommitOptions
|
|
}
|
|
|
|
// PathChange is a rename-aware path-level Git change. For renames and copies,
|
|
// OldPath and NewPath are both populated. Other changes use NewPath.
|
|
type PathChange struct {
|
|
Status string `json:"status"`
|
|
OldPath string `json:"old_path,omitempty"`
|
|
NewPath string `json:"new_path,omitempty"`
|
|
}
|
|
|
|
// NewGitStore returns a Git store with Hop's controlled commit identity.
|
|
func NewGitStore() *GitStore {
|
|
return &GitStore{
|
|
Binary: defaultGitBinary,
|
|
Identity: GitIdentity{
|
|
Name: defaultCommitName,
|
|
Email: defaultCommitMail,
|
|
},
|
|
Now: time.Now,
|
|
}
|
|
}
|
|
|
|
// EnsureRepository opens the repository containing path, or initializes one at
|
|
// path when none exists. It also keeps Hop's private directory out of snapshots.
|
|
func EnsureRepository(path string) (*Repository, error) {
|
|
return NewGitStore().Ensure(context.Background(), path)
|
|
}
|
|
|
|
// OpenRepository opens the repository containing path and adds .hop/ to its
|
|
// per-repository exclude file.
|
|
func OpenRepository(path string) (*Repository, error) {
|
|
return NewGitStore().Open(context.Background(), path)
|
|
}
|
|
|
|
// FindRepositoryRoot returns the top-level worktree containing path.
|
|
func FindRepositoryRoot(path string) (string, error) {
|
|
return NewGitStore().FindRoot(context.Background(), path)
|
|
}
|
|
|
|
// Ensure opens the repository containing path, or initializes a new repository
|
|
// at path if it is not already inside one.
|
|
func (s *GitStore) Ensure(ctx context.Context, path string) (*Repository, error) {
|
|
path, err := absolutePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
|
|
if root, findErr := s.FindRoot(ctx, filepath.Dir(path)); findErr == nil {
|
|
return s.openRoot(ctx, root, true)
|
|
}
|
|
return nil, fmt.Errorf("cannot initialize a repository at non-directory %q", path)
|
|
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("inspect repository path: %w", statErr)
|
|
}
|
|
|
|
if root, findErr := s.FindRoot(ctx, path); findErr == nil {
|
|
return s.openRoot(ctx, root, true)
|
|
}
|
|
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create repository directory: %w", err)
|
|
}
|
|
if _, err := s.run(ctx, path, nil, nil, "init", "--quiet", path); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.openRoot(ctx, path, true)
|
|
}
|
|
|
|
// Open opens the non-bare repository containing path.
|
|
func (s *GitStore) Open(ctx context.Context, path string) (*Repository, error) {
|
|
root, err := s.FindRoot(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.openRoot(ctx, root, true)
|
|
}
|
|
|
|
// FindRoot locates the top-level non-bare worktree containing path.
|
|
func (s *GitStore) FindRoot(ctx context.Context, path string) (string, error) {
|
|
path, err := existingDirectory(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
output, err := s.run(ctx, path, nil, nil, "rev-parse", "--show-toplevel")
|
|
if err != nil {
|
|
return "", fmt.Errorf("find Git repository from %q: %w", path, err)
|
|
}
|
|
root := trimLine(output)
|
|
if root == "" {
|
|
return "", fmt.Errorf("git returned an empty repository root for %q", path)
|
|
}
|
|
if !filepath.IsAbs(root) {
|
|
root = filepath.Join(path, root)
|
|
}
|
|
return filepath.Clean(root), nil
|
|
}
|
|
|
|
func (s *GitStore) openRoot(ctx context.Context, root string, addExclude bool) (*Repository, error) {
|
|
root, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve repository root: %w", err)
|
|
}
|
|
inside, err := s.run(ctx, root, nil, nil, "rev-parse", "--is-inside-work-tree")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if trimLine(inside) != "true" {
|
|
return nil, fmt.Errorf("%q is not a non-bare Git worktree", root)
|
|
}
|
|
|
|
gitDirOutput, err := s.run(ctx, root, nil, nil, "rev-parse", "--absolute-git-dir")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commonOutput, err := s.run(ctx, root, nil, nil, "rev-parse", "--git-common-dir")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
gitDir := resolveGitPath(root, trimLine(gitDirOutput))
|
|
commonGitDir := resolveGitPath(root, trimLine(commonOutput))
|
|
repository := &Repository{
|
|
root: filepath.Clean(root),
|
|
gitDir: gitDir,
|
|
commonGitDir: commonGitDir,
|
|
store: s,
|
|
}
|
|
if addExclude {
|
|
if err := repository.EnsureHopExcluded(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return repository, nil
|
|
}
|
|
|
|
// Root returns the absolute top-level directory of this worktree.
|
|
func (r *Repository) Root() string { return r.root }
|
|
|
|
// GitDir returns the absolute per-worktree Git directory.
|
|
func (r *Repository) GitDir() string { return r.gitDir }
|
|
|
|
// CommonGitDir returns the absolute common Git directory shared by linked
|
|
// worktrees.
|
|
func (r *Repository) CommonGitDir() string { return r.commonGitDir }
|
|
|
|
// EnsureHopExcluded adds .hop/ to .git/info/exclude without changing tracked
|
|
// files or the repository's public .gitignore.
|
|
func (r *Repository) EnsureHopExcluded() error {
|
|
infoDir := filepath.Join(r.commonGitDir, "info")
|
|
if err := os.MkdirAll(infoDir, 0o755); err != nil {
|
|
return fmt.Errorf("create Git info directory: %w", err)
|
|
}
|
|
excludePath := filepath.Join(infoDir, "exclude")
|
|
contents, err := os.ReadFile(excludePath)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("read Git exclude file: %w", err)
|
|
}
|
|
for _, line := range strings.Split(string(contents), "\n") {
|
|
if strings.TrimSuffix(line, "\r") == ".hop/" {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("open Git exclude file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
if len(contents) > 0 && contents[len(contents)-1] != '\n' {
|
|
if _, err := io.WriteString(file, "\n"); err != nil {
|
|
return fmt.Errorf("complete Git exclude line: %w", err)
|
|
}
|
|
}
|
|
if _, err := io.WriteString(file, ".hop/\n"); err != nil {
|
|
return fmt.Errorf("exclude .hop directory: %w", err)
|
|
}
|
|
return file.Sync()
|
|
}
|
|
|
|
// Head returns the current HEAD commit. exists is false for an unborn branch.
|
|
func (r *Repository) Head(ctx context.Context) (oid string, exists bool, err error) {
|
|
output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
|
|
if err != nil {
|
|
if gitExitCode(err) == 1 {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
return trimLine(output), true, nil
|
|
}
|
|
|
|
// Snapshot records all tracked files and all non-ignored untracked files in the
|
|
// worktree. It uses a disposable index, preserving both the contents and staging
|
|
// state of the user's real index. The synthetic commit is parented to HEAD when
|
|
// HEAD exists; an unborn repository produces a parentless commit.
|
|
func (r *Repository) Snapshot(ctx context.Context, message string) (commitOID, treeOID string, err error) {
|
|
return r.SnapshotWithOptions(ctx, SnapshotOptions{Message: message})
|
|
}
|
|
|
|
// SnapshotWithOptions is Snapshot with explicit synthetic commit controls.
|
|
func (r *Repository) SnapshotWithOptions(ctx context.Context, options SnapshotOptions) (commitOID, treeOID string, err error) {
|
|
indexPath, cleanup, err := r.temporaryIndex(true)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer cleanup()
|
|
|
|
env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
|
|
if _, err := r.run(ctx, env, nil, "add", "-A", "--", "."); err != nil {
|
|
return "", "", fmt.Errorf("snapshot workspace: %w", err)
|
|
}
|
|
treeOutput, err := r.run(ctx, env, nil, "write-tree")
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("write snapshot tree: %w", err)
|
|
}
|
|
treeOID = trimLine(treeOutput)
|
|
|
|
parent, hasParent, err := r.Head(ctx)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
commitOptions := options.Commit
|
|
commitOptions.Message = options.Message
|
|
if commitOptions.Message == "" {
|
|
commitOptions.Message = "hop workspace snapshot"
|
|
}
|
|
if hasParent {
|
|
commitOptions.Parents = []string{parent}
|
|
} else {
|
|
commitOptions.Parents = nil
|
|
}
|
|
commitOID, err = r.CommitTreeWithOptions(ctx, treeOID, commitOptions)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return commitOID, treeOID, nil
|
|
}
|
|
|
|
// CommitTree creates a synthetic commit from tree. Parent order is preserved.
|
|
func (r *Repository) CommitTree(ctx context.Context, tree string, parents []string, message string) (string, error) {
|
|
return r.CommitTreeWithOptions(ctx, tree, CommitOptions{
|
|
Message: message,
|
|
Parents: append([]string(nil), parents...),
|
|
})
|
|
}
|
|
|
|
// CommitTreeWithOptions creates a synthetic commit without invoking hooks or
|
|
// consulting the user's Git identity. It does not update any ref.
|
|
func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) {
|
|
if err := validObjectName(tree); err != nil {
|
|
return "", fmt.Errorf("invalid tree: %w", err)
|
|
}
|
|
for _, parent := range options.Parents {
|
|
if err := validObjectName(parent); err != nil {
|
|
return "", fmt.Errorf("invalid parent: %w", err)
|
|
}
|
|
}
|
|
|
|
author := r.store.identity(options.Author)
|
|
committer := r.store.identity(options.Committer)
|
|
if options.Committer.Name == "" && options.Committer.Email == "" {
|
|
committer = author
|
|
}
|
|
if err := validateIdentity(author); err != nil {
|
|
return "", fmt.Errorf("invalid author identity: %w", err)
|
|
}
|
|
if err := validateIdentity(committer); err != nil {
|
|
return "", fmt.Errorf("invalid committer identity: %w", err)
|
|
}
|
|
|
|
authorTime := options.AuthorTime
|
|
if authorTime.IsZero() {
|
|
authorTime = r.store.now()
|
|
}
|
|
committerTime := options.CommitterTime
|
|
if committerTime.IsZero() {
|
|
committerTime = authorTime
|
|
}
|
|
env := []string{
|
|
"GIT_AUTHOR_NAME=" + author.Name,
|
|
"GIT_AUTHOR_EMAIL=" + author.Email,
|
|
"GIT_AUTHOR_DATE=" + authorTime.Format(time.RFC3339),
|
|
"GIT_COMMITTER_NAME=" + committer.Name,
|
|
"GIT_COMMITTER_EMAIL=" + committer.Email,
|
|
"GIT_COMMITTER_DATE=" + committerTime.Format(time.RFC3339),
|
|
}
|
|
|
|
args := []string{"-c", "commit.gpgSign=false", "-c", "i18n.commitEncoding=UTF-8", "commit-tree", tree}
|
|
for _, parent := range options.Parents {
|
|
args = append(args, "-p", parent)
|
|
}
|
|
message := options.Message
|
|
if message == "" {
|
|
message = "hop synthetic commit"
|
|
}
|
|
output, err := r.run(ctx, env, []byte(message), args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create synthetic commit: %w", err)
|
|
}
|
|
return trimLine(output), nil
|
|
}
|
|
|
|
// HiddenRef returns the full private ref name for name.
|
|
func HiddenRef(name string) (string, error) {
|
|
if strings.HasPrefix(name, hiddenRefPrefix) {
|
|
if err := checkRefName(name); err != nil {
|
|
return "", err
|
|
}
|
|
return name, nil
|
|
}
|
|
if strings.HasPrefix(name, "refs/") {
|
|
return "", fmt.Errorf("hidden ref must be below %s", hiddenRefPrefix)
|
|
}
|
|
name = strings.TrimPrefix(name, "/")
|
|
ref := hiddenRefPrefix + name
|
|
if err := checkRefName(ref); err != nil {
|
|
return "", err
|
|
}
|
|
return ref, nil
|
|
}
|
|
|
|
// ReadHiddenRef reads refs/hop/name. exists is false when it has not been
|
|
// created yet.
|
|
func (r *Repository) ReadHiddenRef(ctx context.Context, name string) (oid string, exists bool, err error) {
|
|
ref, err := HiddenRef(name)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return r.ReadRef(ctx, ref)
|
|
}
|
|
|
|
// UpdateHiddenRef atomically updates refs/hop/name. With no expectedOld value,
|
|
// it is unconditional. Passing expectedOld performs compare-and-swap; an empty
|
|
// expected value means the ref must not exist.
|
|
func (r *Repository) UpdateHiddenRef(ctx context.Context, name, newOID string, expectedOld ...string) error {
|
|
ref, err := HiddenRef(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.UpdateRef(ctx, ref, newOID, expectedOld...)
|
|
}
|
|
|
|
// ReadRef reads an exact, fully qualified ref without resolving ambiguous short
|
|
// names. exists is false when the ref is absent.
|
|
func (r *Repository) ReadRef(ctx context.Context, ref string) (oid string, exists bool, err error) {
|
|
if err := r.validateRef(ctx, ref); err != nil {
|
|
return "", false, err
|
|
}
|
|
output, err := r.run(ctx, nil, nil, "show-ref", "--verify", "--hash", ref)
|
|
if err != nil {
|
|
if gitExitCode(err) == 1 {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
return trimLine(output), true, nil
|
|
}
|
|
|
|
// UpdateRef updates a fully qualified ref. Supplying expectedOld makes the
|
|
// operation compare-and-swap; expectedOld == "" requires an absent ref.
|
|
func (r *Repository) UpdateRef(ctx context.Context, ref, newOID string, expectedOld ...string) error {
|
|
if err := r.validateRef(ctx, ref); err != nil {
|
|
return err
|
|
}
|
|
if err := validObjectName(newOID); err != nil {
|
|
return fmt.Errorf("invalid new object ID: %w", err)
|
|
}
|
|
if len(expectedOld) > 1 {
|
|
return fmt.Errorf("update ref accepts at most one expected old object ID")
|
|
}
|
|
args := []string{"update-ref", "--create-reflog", "-m", "hop update", ref, newOID}
|
|
if len(expectedOld) == 1 {
|
|
old := expectedOld[0]
|
|
if old == "" {
|
|
var err error
|
|
old, err = r.ZeroOID(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if err := validObjectName(old); err != nil {
|
|
return fmt.Errorf("invalid expected object ID: %w", err)
|
|
}
|
|
args = append(args, old)
|
|
}
|
|
if _, err := r.run(ctx, nil, nil, args...); err != nil {
|
|
return fmt.Errorf("update ref %s: %w", ref, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteRef deletes a fully qualified ref, optionally only if it still has the
|
|
// expected object ID.
|
|
func (r *Repository) DeleteRef(ctx context.Context, ref string, expectedOld ...string) error {
|
|
if err := r.validateRef(ctx, ref); err != nil {
|
|
return err
|
|
}
|
|
if len(expectedOld) > 1 {
|
|
return fmt.Errorf("delete ref accepts at most one expected old object ID")
|
|
}
|
|
args := []string{"update-ref", "-d", ref}
|
|
if len(expectedOld) == 1 && expectedOld[0] != "" {
|
|
if err := validObjectName(expectedOld[0]); err != nil {
|
|
return fmt.Errorf("invalid expected object ID: %w", err)
|
|
}
|
|
args = append(args, expectedOld[0])
|
|
}
|
|
if _, err := r.run(ctx, nil, nil, args...); err != nil {
|
|
return fmt.Errorf("delete ref %s: %w", ref, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddDetachedWorktree materializes commit at path without creating or moving a
|
|
// branch. The returned repository is rooted at the new linked worktree.
|
|
func (r *Repository) AddDetachedWorktree(ctx context.Context, path, commit string) (*Repository, error) {
|
|
if err := validObjectName(commit); err != nil {
|
|
return nil, fmt.Errorf("invalid worktree commit: %w", err)
|
|
}
|
|
path, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve worktree path: %w", err)
|
|
}
|
|
if _, err := r.run(ctx, nil, nil, "worktree", "add", "--detach", path, commit); err != nil {
|
|
return nil, fmt.Errorf("create detached worktree: %w", err)
|
|
}
|
|
worktree, err := r.store.openRoot(ctx, path, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return worktree, nil
|
|
}
|
|
|
|
// RemoveWorktree removes a linked worktree. force allows removal when it has
|
|
// local modifications or untracked files.
|
|
func (r *Repository) RemoveWorktree(ctx context.Context, path string, force bool) error {
|
|
path, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve worktree path: %w", err)
|
|
}
|
|
args := []string{"worktree", "remove"}
|
|
if force {
|
|
args = append(args, "--force")
|
|
}
|
|
args = append(args, path)
|
|
if _, err := r.run(ctx, nil, nil, args...); err != nil {
|
|
return fmt.Errorf("remove worktree: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Diff returns a binary-capable, full-index patch between two commits or trees.
|
|
// An empty endpoint denotes Git's empty tree, which supports unborn histories.
|
|
func (r *Repository) Diff(ctx context.Context, from, to string) (string, error) {
|
|
fromTree, err := r.resolveTree(ctx, from)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
toTree, err := r.resolveTree(ctx, to)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
output, err := r.run(ctx, nil, nil,
|
|
"diff", "--no-ext-diff", "--no-textconv", "--binary", "--full-index", "--find-renames",
|
|
fromTree, toTree, "--",
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("diff trees: %w", err)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
// ChangedPathDetails returns Git's rename-aware path changes.
|
|
func (r *Repository) ChangedPathDetails(ctx context.Context, from, to string) ([]PathChange, error) {
|
|
fromTree, err := r.resolveTree(ctx, from)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
toTree, err := r.resolveTree(ctx, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output, err := r.run(ctx, nil, nil,
|
|
"diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", "--find-renames",
|
|
fromTree, toTree, "--",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list changed paths: %w", err)
|
|
}
|
|
return parseNameStatus([]byte(output))
|
|
}
|
|
|
|
// ChangedPaths returns the set of affected paths in bytewise order. A rename
|
|
// or copy contributes both its old and new path, making overlap checks safe.
|
|
func (r *Repository) ChangedPaths(ctx context.Context, from, to string) ([]string, error) {
|
|
changes, err := r.ChangedPathDetails(ctx, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := make(map[string]struct{}, len(changes))
|
|
for _, change := range changes {
|
|
if change.OldPath != "" {
|
|
paths[change.OldPath] = struct{}{}
|
|
}
|
|
if change.NewPath != "" {
|
|
paths[change.NewPath] = struct{}{}
|
|
}
|
|
}
|
|
result := make([]string, 0, len(paths))
|
|
for path := range paths {
|
|
result = append(result, path)
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
// TrackedPaths lists index entries at or below path. Hop uses this before
|
|
// initialization to avoid hiding or overwriting a repository that already
|
|
// treats .hop as user-owned source content.
|
|
func (r *Repository) TrackedPaths(ctx context.Context, path string) ([]string, error) {
|
|
if path == "" {
|
|
return nil, fmt.Errorf("tracked path query requires a path")
|
|
}
|
|
output, err := r.run(ctx, nil, nil, "ls-files", "-z", "--", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tracked paths below %s: %w", path, err)
|
|
}
|
|
parts := splitNull([]byte(output))
|
|
paths := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if part != "" {
|
|
paths = append(paths, part)
|
|
}
|
|
}
|
|
sort.Strings(paths)
|
|
return paths, nil
|
|
}
|
|
|
|
// ComposeTrees conservatively composes the changes base->theirs onto ours.
|
|
// It uses Git's three-tree index merge, so a path independently changed to
|
|
// different content is reported as a conflict instead of being rewritten. No
|
|
// worktree or user index is read or changed. On conflict, tree is empty and the
|
|
// sorted conflicting path set is returned with a nil error.
|
|
func (r *Repository) ComposeTrees(ctx context.Context, base, ours, theirs string) (tree string, conflicts []string, err error) {
|
|
baseTree, err := r.resolveTree(ctx, base)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("resolve base tree: %w", err)
|
|
}
|
|
oursTree, err := r.resolveTree(ctx, ours)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("resolve current tree: %w", err)
|
|
}
|
|
theirsTree, err := r.resolveTree(ctx, theirs)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("resolve proposal tree: %w", err)
|
|
}
|
|
|
|
indexPath, cleanup, err := r.temporaryIndex(false)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer cleanup()
|
|
env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
|
|
// --aggressive resolves only additional provably-trivial cases (for example,
|
|
// a deletion on one side while the other side is unchanged). It does not
|
|
// attempt a content merge of independently modified files.
|
|
_, mergeErr := r.run(ctx, env, nil, "read-tree", "-m", "--aggressive", baseTree, oursTree, theirsTree)
|
|
|
|
unmergedOutput, listErr := r.run(ctx, env, nil, "ls-files", "-u", "-z")
|
|
if listErr == nil {
|
|
conflicts = parseUnmergedPaths([]byte(unmergedOutput))
|
|
}
|
|
if len(conflicts) > 0 {
|
|
return "", conflicts, nil
|
|
}
|
|
if mergeErr != nil {
|
|
return "", nil, fmt.Errorf("compose trees: %w", mergeErr)
|
|
}
|
|
if listErr != nil {
|
|
return "", nil, fmt.Errorf("inspect composed index: %w", listErr)
|
|
}
|
|
|
|
treeOutput, err := r.run(ctx, env, nil, "write-tree")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("write composed tree: %w", err)
|
|
}
|
|
return trimLine(treeOutput), nil, nil
|
|
}
|
|
|
|
// VerifyObject verifies that name resolves to an object in this repository.
|
|
func (r *Repository) VerifyObject(ctx context.Context, name string) error {
|
|
if err := validObjectName(name); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.run(ctx, nil, nil, "cat-file", "-e", name+"^{object}"); err != nil {
|
|
return fmt.Errorf("verify Git object %s: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VerifyObjects verifies each named object.
|
|
func (r *Repository) VerifyObjects(ctx context.Context, names ...string) error {
|
|
for _, name := range names {
|
|
if err := r.VerifyObject(ctx, name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verify checks the connectivity and validity of the repository's reachable
|
|
// object graph. Dangling Hop snapshots are allowed and do not produce noise.
|
|
func (r *Repository) Verify(ctx context.Context) error {
|
|
if _, err := r.run(ctx, nil, nil, "fsck", "--connectivity-only", "--no-dangling"); err != nil {
|
|
return fmt.Errorf("verify Git object database: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EmptyTree returns the object ID of Git's empty tree for this repository's
|
|
// object format.
|
|
func (r *Repository) EmptyTree(ctx context.Context) (string, error) {
|
|
output, err := r.run(ctx, nil, nil, "mktree")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create empty tree: %w", err)
|
|
}
|
|
return trimLine(output), nil
|
|
}
|
|
|
|
// ZeroOID returns the all-zero object ID of the correct length for this
|
|
// repository (SHA-1 or SHA-256).
|
|
func (r *Repository) ZeroOID(ctx context.Context) (string, error) {
|
|
output, err := r.run(ctx, nil, nil, "rev-parse", "--show-object-format")
|
|
if err != nil {
|
|
return "", fmt.Errorf("read Git object format: %w", err)
|
|
}
|
|
switch trimLine(output) {
|
|
case "sha1":
|
|
return strings.Repeat("0", 40), nil
|
|
case "sha256":
|
|
return strings.Repeat("0", 64), nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported Git object format %q", trimLine(output))
|
|
}
|
|
}
|
|
|
|
func (r *Repository) resolveTree(ctx context.Context, object string) (string, error) {
|
|
if object == "" {
|
|
return r.EmptyTree(ctx)
|
|
}
|
|
if err := validObjectName(object); err != nil {
|
|
return "", err
|
|
}
|
|
output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", object+"^{tree}")
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve tree %s: %w", object, err)
|
|
}
|
|
return trimLine(output), nil
|
|
}
|
|
|
|
func (r *Repository) validateRef(ctx context.Context, ref string) error {
|
|
if !strings.HasPrefix(ref, "refs/") {
|
|
return fmt.Errorf("ref must be fully qualified: %q", ref)
|
|
}
|
|
if _, err := r.run(ctx, nil, nil, "check-ref-format", ref); err != nil {
|
|
return fmt.Errorf("invalid ref %q: %w", ref, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) temporaryIndex(seedFromUser bool) (string, func(), error) {
|
|
if err := os.MkdirAll(r.gitDir, 0o755); err != nil {
|
|
return "", nil, fmt.Errorf("prepare Git directory: %w", err)
|
|
}
|
|
temporary, err := os.CreateTemp(r.gitDir, "hop-index-*")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("create temporary Git index: %w", err)
|
|
}
|
|
path := temporary.Name()
|
|
if err := temporary.Close(); err != nil {
|
|
os.Remove(path)
|
|
return "", nil, fmt.Errorf("close temporary Git index: %w", err)
|
|
}
|
|
cleanup := func() {
|
|
_ = os.Remove(path)
|
|
_ = os.Remove(path + ".lock")
|
|
}
|
|
|
|
if seedFromUser {
|
|
userIndex := filepath.Join(r.gitDir, "index")
|
|
contents, readErr := os.ReadFile(userIndex)
|
|
switch {
|
|
case readErr == nil:
|
|
if writeErr := os.WriteFile(path, contents, 0o600); writeErr != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("seed temporary Git index: %w", writeErr)
|
|
}
|
|
case errors.Is(readErr, os.ErrNotExist):
|
|
if removeErr := os.Remove(path); removeErr != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("prepare empty temporary Git index: %w", removeErr)
|
|
}
|
|
default:
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("read user Git index: %w", readErr)
|
|
}
|
|
} else if err := os.Remove(path); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("prepare temporary Git index: %w", err)
|
|
}
|
|
return path, cleanup, nil
|
|
}
|
|
|
|
func (r *Repository) run(ctx context.Context, env []string, stdin []byte, args ...string) (string, error) {
|
|
return r.store.run(ctx, r.root, env, stdin, args...)
|
|
}
|
|
|
|
func (s *GitStore) run(ctx context.Context, directory string, env []string, stdin []byte, args ...string) (string, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
binary := s.Binary
|
|
if binary == "" {
|
|
binary = defaultGitBinary
|
|
}
|
|
command := exec.CommandContext(ctx, binary, args...)
|
|
command.Dir = directory
|
|
command.Env = mergeEnvironment(os.Environ(), append([]string{"LC_ALL=C"}, env...))
|
|
if stdin != nil {
|
|
command.Stdin = bytes.NewReader(stdin)
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
command.Stdout = &stdout
|
|
command.Stderr = &stderr
|
|
err := command.Run()
|
|
if err != nil {
|
|
exitCode := -1
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
exitCode = exitErr.ExitCode()
|
|
}
|
|
return stdout.String(), &GitError{
|
|
Args: append([]string(nil), args...),
|
|
ExitCode: exitCode,
|
|
Stdout: stdout.String(),
|
|
Stderr: stderr.String(),
|
|
Err: err,
|
|
}
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
func (s *GitStore) identity(override GitIdentity) GitIdentity {
|
|
identity := s.Identity
|
|
if identity.Name == "" {
|
|
identity.Name = defaultCommitName
|
|
}
|
|
if identity.Email == "" {
|
|
identity.Email = defaultCommitMail
|
|
}
|
|
if override.Name != "" {
|
|
identity.Name = override.Name
|
|
}
|
|
if override.Email != "" {
|
|
identity.Email = override.Email
|
|
}
|
|
return identity
|
|
}
|
|
|
|
func (s *GitStore) now() time.Time {
|
|
if s.Now != nil {
|
|
return s.Now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func parseNameStatus(output []byte) ([]PathChange, error) {
|
|
fields := splitNull(output)
|
|
changes := make([]PathChange, 0, len(fields)/2)
|
|
for index := 0; index < len(fields); {
|
|
status := fields[index]
|
|
index++
|
|
if status == "" {
|
|
continue
|
|
}
|
|
kind := status[0]
|
|
if kind == 'R' || kind == 'C' {
|
|
if index+1 >= len(fields) {
|
|
return nil, fmt.Errorf("malformed rename entry in git diff --name-status")
|
|
}
|
|
changes = append(changes, PathChange{
|
|
Status: status,
|
|
OldPath: fields[index],
|
|
NewPath: fields[index+1],
|
|
})
|
|
index += 2
|
|
continue
|
|
}
|
|
if index >= len(fields) {
|
|
return nil, fmt.Errorf("malformed entry in git diff --name-status")
|
|
}
|
|
changes = append(changes, PathChange{Status: status, NewPath: fields[index]})
|
|
index++
|
|
}
|
|
return changes, nil
|
|
}
|
|
|
|
func parseUnmergedPaths(output []byte) []string {
|
|
set := make(map[string]struct{})
|
|
for _, record := range splitNull(output) {
|
|
if record == "" {
|
|
continue
|
|
}
|
|
if tab := strings.IndexByte(record, '\t'); tab >= 0 && tab+1 < len(record) {
|
|
set[record[tab+1:]] = struct{}{}
|
|
}
|
|
}
|
|
paths := make([]string, 0, len(set))
|
|
for path := range set {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
return paths
|
|
}
|
|
|
|
func splitNull(value []byte) []string {
|
|
if len(value) == 0 {
|
|
return nil
|
|
}
|
|
parts := bytes.Split(value, []byte{0})
|
|
if len(parts) > 0 && len(parts[len(parts)-1]) == 0 {
|
|
parts = parts[:len(parts)-1]
|
|
}
|
|
result := make([]string, len(parts))
|
|
for index, part := range parts {
|
|
result[index] = string(part)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func validObjectName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("object name is empty")
|
|
}
|
|
if strings.HasPrefix(name, "-") || strings.ContainsAny(name, "\x00\r\n") {
|
|
return fmt.Errorf("unsafe object name %q", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateIdentity(identity GitIdentity) error {
|
|
if identity.Name == "" || identity.Email == "" {
|
|
return fmt.Errorf("name and email are required")
|
|
}
|
|
if strings.ContainsAny(identity.Name, "\x00\r\n<>") {
|
|
return fmt.Errorf("unsafe name")
|
|
}
|
|
if strings.ContainsAny(identity.Email, "\x00\r\n<>") {
|
|
return fmt.Errorf("unsafe email")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkRefName(ref string) error {
|
|
if ref == "" || strings.ContainsAny(ref, "\x00\r\n") {
|
|
return fmt.Errorf("invalid ref %q", ref)
|
|
}
|
|
if !strings.HasPrefix(ref, "refs/") || strings.HasSuffix(ref, "/") ||
|
|
strings.Contains(ref, "..") || strings.Contains(ref, "@{") ||
|
|
strings.ContainsAny(ref, " ~^:?*[\\") {
|
|
return fmt.Errorf("invalid ref %q", ref)
|
|
}
|
|
for _, component := range strings.Split(ref, "/") {
|
|
if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".") || strings.HasSuffix(component, ".lock") {
|
|
return fmt.Errorf("invalid ref %q", ref)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func gitExitCode(err error) int {
|
|
var gitErr *GitError
|
|
if errors.As(err, &gitErr) {
|
|
return gitErr.ExitCode
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func mergeEnvironment(base, overrides []string) []string {
|
|
values := make(map[string]string, len(base)+len(overrides))
|
|
order := make([]string, 0, len(base)+len(overrides))
|
|
add := func(entry string) {
|
|
key := entry
|
|
if equals := strings.IndexByte(entry, '='); equals >= 0 {
|
|
key = entry[:equals]
|
|
}
|
|
if _, exists := values[key]; !exists {
|
|
order = append(order, key)
|
|
}
|
|
values[key] = entry
|
|
}
|
|
for _, entry := range base {
|
|
add(entry)
|
|
}
|
|
for _, entry := range overrides {
|
|
add(entry)
|
|
}
|
|
result := make([]string, 0, len(values))
|
|
for _, key := range order {
|
|
result = append(result, values[key])
|
|
}
|
|
return result
|
|
}
|
|
|
|
func absolutePath(path string) (string, error) {
|
|
if path == "" {
|
|
path = "."
|
|
}
|
|
absolute, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve path %q: %w", path, err)
|
|
}
|
|
return filepath.Clean(absolute), nil
|
|
}
|
|
|
|
func existingDirectory(path string) (string, error) {
|
|
path, err := absolutePath(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("inspect path %q: %w", path, err)
|
|
}
|
|
if !info.IsDir() {
|
|
path = filepath.Dir(path)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func resolveGitPath(root, path string) string {
|
|
if filepath.IsAbs(path) {
|
|
return filepath.Clean(path)
|
|
}
|
|
return filepath.Clean(filepath.Join(root, path))
|
|
}
|
|
|
|
func trimLine(value string) string {
|
|
return strings.TrimSuffix(strings.TrimSuffix(value, "\n"), "\r")
|
|
}
|