23a4891099
Hop-State: A_06FN4YF9MKR7SWE1Y7GZDJR Hop-Proposal: R_06FN4YEJF4PMSYPMQ7T3SBG Hop-Task: T_06FN3MBF98GWD4NA5PA1RWG Hop-Attempt: AT_06FN4XMZP6EPRVNAAQWA8K8
1374 lines
41 KiB
Go
1374 lines
41 KiB
Go
package hop
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"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.openRootForEnsure(ctx, root)
|
|
}
|
|
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.openRootForEnsure(ctx, root)
|
|
}
|
|
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create repository directory: %w", err)
|
|
}
|
|
lockPath, err := repositoryInitLockPath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
release, err := acquireFileLock(ctx, lockPath, "Hop repository initialization")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
|
|
// A concurrent Hop process may have initialized this directory while this
|
|
// process waited for the per-user bootstrap lock.
|
|
if root, findErr := s.FindRoot(ctx, path); findErr == nil {
|
|
return s.openRoot(ctx, root, true)
|
|
}
|
|
if _, err := s.run(ctx, path, nil, nil, "init", "--quiet", path); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.openRoot(ctx, path, true)
|
|
}
|
|
|
|
func (s *GitStore) openRootForEnsure(ctx context.Context, root string) (*Repository, error) {
|
|
lockPath, err := repositoryInitLockPath(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
release, err := acquireFileLock(ctx, lockPath, "Hop repository initialization")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
return s.openRoot(ctx, root, 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)
|
|
}
|
|
// The disposable index is copied from the worktree index and therefore
|
|
// carries its stat cache. An agent can rewrite a file to the same size within
|
|
// the filesystem timestamp granularity, making a plain `git add -A`
|
|
// intermittently treat fresh content as unchanged. Renormalizing forces Git
|
|
// to hash every tracked path again while retaining additions/deletions staged
|
|
// by the first pass.
|
|
if _, err := r.run(ctx, env, nil, "add", "--renormalize", "--", "."); err != nil {
|
|
return "", "", fmt.Errorf("rehash workspace snapshot: %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
|
|
}
|
|
|
|
// WorktreeTree snapshots the visible worktree relative to an expected Hop
|
|
// tree. The caller's real Git index is never read or changed. Seeding from the
|
|
// expected tree makes Hop-projected files remain visible to the snapshot even
|
|
// when they are untracked by the user's branch or matched by an ignore rule.
|
|
func (r *Repository) WorktreeTree(ctx context.Context, expected string) (string, error) {
|
|
expectedTree, err := r.resolveTree(ctx, expected)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
indexPath, cleanup, err := r.temporaryIndex(false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer cleanup()
|
|
|
|
env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
|
|
if _, err := r.run(ctx, env, nil, "read-tree", expectedTree); err != nil {
|
|
return "", fmt.Errorf("seed visible-root snapshot: %w", err)
|
|
}
|
|
if _, err := r.run(ctx, env, nil, "add", "-A", "--", "."); err != nil {
|
|
return "", fmt.Errorf("snapshot visible project root: %w", err)
|
|
}
|
|
output, err := r.run(ctx, env, nil, "write-tree")
|
|
if err != nil {
|
|
return "", fmt.Errorf("write visible-root tree: %w", err)
|
|
}
|
|
return trimLine(output), nil
|
|
}
|
|
|
|
// CheckIndexSafe verifies that the user's real index contains no staged state
|
|
// outside either their current HEAD or the Hop tree already visible in the
|
|
// project root. Hop never writes this index, but refusing divergent staging
|
|
// prevents a landing from obscuring the user's in-progress Git operation.
|
|
func (r *Repository) CheckIndexSafe(ctx context.Context, visibleTree string) error {
|
|
visibleTree, err := r.resolveTree(ctx, visibleTree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
head, exists, err := r.Head(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
headTree := ""
|
|
if exists {
|
|
headTree, err = r.resolveTree(ctx, head)
|
|
} else {
|
|
headTree, err = r.EmptyTree(ctx)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
indexTree, err := r.userIndexTree(ctx)
|
|
if err != nil {
|
|
return &RootConflictError{Reason: "visible project root has unmerged or intent-to-add entries in the real Git index"}
|
|
}
|
|
if indexTree == headTree || indexTree == visibleTree {
|
|
return nil
|
|
}
|
|
paths, err := r.ChangedPaths(ctx, headTree, indexTree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return &RootConflictError{
|
|
Paths: paths,
|
|
Reason: "visible project root has staged Git changes that do not match HEAD or its materialized Hop state",
|
|
}
|
|
}
|
|
|
|
func (r *Repository) userIndexTree(ctx context.Context) (string, error) {
|
|
indexPath := filepath.Join(r.gitDir, "index")
|
|
if _, err := os.Stat(indexPath); errors.Is(err, os.ErrNotExist) {
|
|
return r.EmptyTree(ctx)
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("inspect real Git index: %w", err)
|
|
}
|
|
output, err := r.run(ctx, []string{
|
|
"GIT_INDEX_FILE=" + indexPath,
|
|
"GIT_OPTIONAL_LOCKS=0",
|
|
}, nil, "write-tree")
|
|
if err != nil {
|
|
return "", fmt.Errorf("read real Git index tree: %w", err)
|
|
}
|
|
return trimLine(output), nil
|
|
}
|
|
|
|
// MaterializationConflicts finds filesystem entries that a tree projection
|
|
// would overwrite even though they are absent from the source tree. This
|
|
// catches ignored files, which intentionally do not appear in WorktreeTree.
|
|
func (r *Repository) MaterializationConflicts(ctx context.Context, from, to string) ([]string, error) {
|
|
fromPaths, err := r.treeLeafPaths(ctx, from)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
toPaths, err := r.treeLeafPaths(ctx, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conflicts := map[string]struct{}{}
|
|
for path := range toPaths {
|
|
if _, existed := fromPaths[path]; existed {
|
|
continue
|
|
}
|
|
parts := strings.Split(path, "/")
|
|
for index := range parts {
|
|
prefix := strings.Join(parts[:index+1], "/")
|
|
info, statErr := os.Lstat(filepath.Join(r.root, filepath.FromSlash(prefix)))
|
|
if statErr != nil {
|
|
if errors.Is(statErr, os.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
|
break
|
|
}
|
|
return nil, fmt.Errorf("inspect visible path %s: %w", prefix, statErr)
|
|
}
|
|
if index < len(parts)-1 {
|
|
if info.IsDir() {
|
|
continue
|
|
}
|
|
if _, expected := fromPaths[prefix]; expected {
|
|
break
|
|
}
|
|
conflicts[prefix] = struct{}{}
|
|
break
|
|
}
|
|
if info.IsDir() {
|
|
unexpected, walkErr := r.unexpectedDirectoryLeaves(path, fromPaths)
|
|
if walkErr != nil {
|
|
return nil, walkErr
|
|
}
|
|
for _, unexpectedPath := range unexpected {
|
|
conflicts[unexpectedPath] = struct{}{}
|
|
}
|
|
continue
|
|
}
|
|
conflicts[path] = struct{}{}
|
|
}
|
|
}
|
|
paths := make([]string, 0, len(conflicts))
|
|
for path := range conflicts {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
return paths, nil
|
|
}
|
|
|
|
func (r *Repository) unexpectedDirectoryLeaves(path string, expected map[string]struct{}) ([]string, error) {
|
|
root := filepath.Join(r.root, filepath.FromSlash(path))
|
|
var conflicts []string
|
|
err := filepath.WalkDir(root, func(candidate string, entry os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if candidate == root || entry.IsDir() {
|
|
return nil
|
|
}
|
|
relative, err := filepath.Rel(r.root, candidate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relative = filepath.ToSlash(relative)
|
|
if _, ok := expected[relative]; !ok {
|
|
conflicts = append(conflicts, relative)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect visible directory %s: %w", path, err)
|
|
}
|
|
sort.Strings(conflicts)
|
|
return conflicts, nil
|
|
}
|
|
|
|
// MaterializeTree updates only the visible worktree from one accepted tree to
|
|
// another. HEAD, the current branch, and the user's real index remain exactly
|
|
// where they were. The operation fails closed when the worktree no longer
|
|
// matches from or an ignored destination would be overwritten.
|
|
func (r *Repository) MaterializeTree(ctx context.Context, from, to string) error {
|
|
fromTree, err := r.resolveTree(ctx, from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
toTree, err := r.resolveTree(ctx, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := r.CheckIndexSafe(ctx, fromTree); err != nil {
|
|
return err
|
|
}
|
|
actualTree, err := r.WorktreeTree(ctx, fromTree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if actualTree != fromTree {
|
|
paths, pathErr := r.ChangedPaths(ctx, fromTree, actualTree)
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
return &RootConflictError{Paths: paths}
|
|
}
|
|
conflicts, err := r.MaterializationConflicts(ctx, fromTree, toTree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(conflicts) > 0 {
|
|
return &RootConflictError{
|
|
Paths: conflicts,
|
|
Reason: "visible project root contains ignored or untracked paths that landing would overwrite",
|
|
}
|
|
}
|
|
if fromTree == toTree {
|
|
return nil
|
|
}
|
|
|
|
indexPath, cleanup, err := r.temporaryIndex(false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
|
|
if _, err := r.run(ctx, env, nil, "read-tree", fromTree); err != nil {
|
|
return fmt.Errorf("seed visible-root materialization: %w", err)
|
|
}
|
|
if _, err := r.run(ctx, env, nil, "update-index", "--refresh"); err != nil {
|
|
return &RootConflictError{Reason: "visible project root changed while Hop was preparing to synchronize it"}
|
|
}
|
|
if _, err := r.run(ctx, env, nil, "read-tree", "-m", "-u", fromTree, toTree); err != nil {
|
|
return fmt.Errorf("materialize accepted tree into visible project root: %w", err)
|
|
}
|
|
materializedTree, err := r.WorktreeTree(ctx, toTree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if materializedTree != toTree {
|
|
paths, pathErr := r.ChangedPaths(ctx, toTree, materializedTree)
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
return &RootConflictError{
|
|
Paths: paths,
|
|
Reason: "visible project root changed while Hop was synchronizing it",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) treeLeafPaths(ctx context.Context, object string) (map[string]struct{}, error) {
|
|
tree, err := r.resolveTree(ctx, object)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output, err := r.run(ctx, nil, nil, "ls-tree", "-r", "-z", "--name-only", tree)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list tree paths: %w", err)
|
|
}
|
|
paths := make(map[string]struct{})
|
|
for _, path := range splitNull([]byte(output)) {
|
|
if path != "" {
|
|
paths[path] = struct{}{}
|
|
}
|
|
}
|
|
return paths, 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 performs Git's real three-way merge without touching a
|
|
// worktree or index. Independent hunks in the same file, identical changes,
|
|
// renames, and compatible mode/content changes merge automatically. On a
|
|
// genuine conflict, tree is the best-effort conflict-marker tree and conflicts
|
|
// lists the paths an agent must reconcile.
|
|
func (r *Repository) ComposeTrees(ctx context.Context, base, ours, theirs string) (tree string, conflicts []string, err error) {
|
|
baseCommit, err := r.resolveCommit(ctx, base)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("resolve base commit: %w", err)
|
|
}
|
|
oursCommit, err := r.resolveCommit(ctx, ours)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("resolve current commit: %w", err)
|
|
}
|
|
theirsCommit, err := r.resolveCommit(ctx, theirs)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("resolve proposal commit: %w", err)
|
|
}
|
|
|
|
output, mergeErr := r.run(ctx, []string{"GIT_OPTIONAL_LOCKS=0"}, nil,
|
|
"-c", "merge.conflictStyle=zdiff3",
|
|
"-c", "merge.renames=true",
|
|
"-c", "merge.directoryRenames=conflict",
|
|
"merge-tree", "--write-tree", "--merge-base", baseCommit,
|
|
"--name-only", "-z", "--no-messages", oursCommit, theirsCommit,
|
|
)
|
|
fields := splitNull([]byte(output))
|
|
if len(fields) == 0 || fields[0] == "" {
|
|
if mergeErr != nil {
|
|
return "", nil, fmt.Errorf("compose trees: %w", mergeErr)
|
|
}
|
|
return "", nil, errors.New("git merge-tree returned no tree")
|
|
}
|
|
tree = fields[0]
|
|
if err := validObjectName(tree); err != nil {
|
|
return "", nil, fmt.Errorf("invalid composed tree: %w", err)
|
|
}
|
|
conflictSet := map[string]struct{}{}
|
|
for _, path := range fields[1:] {
|
|
if path != "" {
|
|
conflictSet[path] = struct{}{}
|
|
}
|
|
}
|
|
for path := range conflictSet {
|
|
conflicts = append(conflicts, path)
|
|
}
|
|
sort.Strings(conflicts)
|
|
if mergeErr == nil {
|
|
if len(conflicts) > 0 {
|
|
return "", nil, errors.New("git merge-tree reported conflict paths with a successful exit")
|
|
}
|
|
return tree, nil, nil
|
|
}
|
|
if gitExitCode(mergeErr) != 1 {
|
|
return "", nil, fmt.Errorf("compose trees: %w", mergeErr)
|
|
}
|
|
if len(conflicts) == 0 {
|
|
// Git documents conflict classes (notably some directory-renames) that
|
|
// produce no conflicted-file list. Give the agent the changed-path union
|
|
// as useful reconciliation candidates instead of converting a real merge
|
|
// conflict into an internal error.
|
|
for _, side := range []string{oursCommit, theirsCommit} {
|
|
paths, pathsErr := r.ChangedPaths(ctx, baseCommit, side)
|
|
if pathsErr != nil {
|
|
return "", nil, fmt.Errorf("derive structural conflict paths: %w", pathsErr)
|
|
}
|
|
for _, path := range paths {
|
|
conflictSet[path] = struct{}{}
|
|
}
|
|
}
|
|
for path := range conflictSet {
|
|
conflicts = append(conflicts, path)
|
|
}
|
|
sort.Strings(conflicts)
|
|
if len(conflicts) == 0 {
|
|
conflicts = []string{"(structural merge conflict; inspect both inputs)"}
|
|
}
|
|
}
|
|
return tree, conflicts, 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) resolveCommit(ctx context.Context, object string) (string, error) {
|
|
if err := validObjectName(object); err != nil {
|
|
return "", err
|
|
}
|
|
output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", object+"^{commit}")
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve commit %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")
|
|
}
|