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") }