Resolve alpha4 integration while preserving accepted Hop behavior

Hop-State: A_06FN4YF9MKR7SWE1Y7GZDJR
Hop-Proposal: R_06FN4YEJF4PMSYPMQ7T3SBG
Hop-Task: T_06FN3MBF98GWD4NA5PA1RWG
Hop-Attempt: AT_06FN4XMZP6EPRVNAAQWA8K8
This commit is contained in:
Hop
2026-07-11 11:39:09 -07:00
parent 4c0c504935
commit 23a4891099
12 changed files with 1304 additions and 74 deletions
+115 -37
View File
@@ -131,7 +131,7 @@ func (s *GitStore) Ensure(ctx context.Context, path string) (*Repository, error)
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 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) {
@@ -139,18 +139,46 @@ func (s *GitStore) Ensure(ctx context.Context, path string) (*Repository, error)
}
if root, findErr := s.FindRoot(ctx, path); findErr == nil {
return s.openRoot(ctx, root, true)
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)
@@ -293,6 +321,15 @@ func (r *Repository) SnapshotWithOptions(ctx context.Context, options SnapshotOp
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)
@@ -880,55 +917,85 @@ func (r *Repository) TrackedPaths(ctx context.Context, path string) ([]string, e
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.
// 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) {
baseTree, err := r.resolveTree(ctx, base)
baseCommit, err := r.resolveCommit(ctx, base)
if err != nil {
return "", nil, fmt.Errorf("resolve base tree: %w", err)
return "", nil, fmt.Errorf("resolve base commit: %w", err)
}
oursTree, err := r.resolveTree(ctx, ours)
oursCommit, err := r.resolveCommit(ctx, ours)
if err != nil {
return "", nil, fmt.Errorf("resolve current tree: %w", err)
return "", nil, fmt.Errorf("resolve current commit: %w", err)
}
theirsTree, err := r.resolveTree(ctx, theirs)
theirsCommit, err := r.resolveCommit(ctx, theirs)
if err != nil {
return "", nil, fmt.Errorf("resolve proposal tree: %w", err)
return "", nil, fmt.Errorf("resolve proposal commit: %w", err)
}
indexPath, cleanup, err := r.temporaryIndex(false)
if err != nil {
return "", nil, 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")
}
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))
tree = fields[0]
if err := validObjectName(tree); err != nil {
return "", nil, fmt.Errorf("invalid composed tree: %w", err)
}
if len(conflicts) > 0 {
return "", conflicts, nil
conflictSet := map[string]struct{}{}
for _, path := range fields[1:] {
if path != "" {
conflictSet[path] = struct{}{}
}
}
if mergeErr != nil {
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 listErr != nil {
return "", nil, fmt.Errorf("inspect composed index: %w", listErr)
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)"}
}
}
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
return tree, conflicts, nil
}
// VerifyObject verifies that name resolves to an object in this repository.
@@ -1002,6 +1069,17 @@ func (r *Repository) resolveTree(ctx context.Context, object string) (string, er
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)