feat(gitcli): add the Git operations of a merge
Branch head, merge base, ancestry, commit range, diff between two commits, commit tree, hash-object for the squash commit and update-ref with the old value as a precondition.
- commit
- 7ce5542b03a4bc89b39cbfe3a1f4ca8425bd857e
- author
- brice · · 2026-10-10 16:49 UTC
- parent
- 858a2fb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +469 -6
M internal/adapters/gitcli/diff.go +18 -6
| @@ -55,19 +55,31 @@ func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Re | |||
| 55 | 55 | if len(commit.Parents) > 0 { | |
| 56 | 56 | base = commit.Parents[0].String() | |
| 57 | 57 | } | |
| 58 | - | ||
| 59 | 58 | detail := domain.CommitDetail{Commit: commit} | |
| 59 | + | detail.Stats, detail.Diff, err = g.diffTrees(ctx, dir, base, commit.ID.String(), limits) | |
| 60 | + | if err != nil { | |
| 61 | + | return domain.CommitDetail{}, fmt.Errorf("diff of %s in %q: %w", commit.ID, repo, err) | |
| 62 | + | } | |
| 63 | + | return detail, nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // diffTrees reads the diff from base to head, two commit ids or tree ids. | |
| 67 | + | func (g *Git) diffTrees(ctx context.Context, dir, base, head string, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) { | |
| 68 | + | var ( | |
| 69 | + | diff domain.Diff | |
| 70 | + | stats domain.DiffStats | |
| 71 | + | ) | |
| 60 | 72 | args := readArgs(dir, "diff-tree", "-r", "-M", "-z", "--raw", "--numstat", "-p", "-U3", | |
| 61 | - | "--no-ext-diff", "--no-textconv", "--no-color", "--end-of-options", base, commit.ID.String()) | |
| 62 | - | err = g.pipe(ctx, args, func(r *bufio.Reader) error { | |
| 73 | + | "--no-ext-diff", "--no-textconv", "--no-color", "--end-of-options", base, head) | |
| 74 | + | err := g.pipe(ctx, args, func(r *bufio.Reader) error { | |
| 63 | 75 | var err error | |
| 64 | - | detail.Diff, detail.Stats, err = readDiff(r, limits) | |
| 76 | + | diff, stats, err = readDiff(r, limits) | |
| 65 | 77 | return err | |
| 66 | 78 | }) | |
| 67 | 79 | if err != nil { | |
| 68 | - | return domain.CommitDetail{}, fmt.Errorf("diff of %s in %q: %w", commit.ID, repo, err) | |
| 80 | + | return domain.DiffStats{}, domain.Diff{}, err | |
| 69 | 81 | } | |
| 70 | - | return detail, nil | |
| 82 | + | return stats, diff, nil | |
| 71 | 83 | } | |
| 72 | 84 | ||
| 73 | 85 | // rawEntry is one file of the diff: a `--raw` record and its `--numstat`. | |
| @@ -55,19 +55,31 @@ func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Re | |||||
| 55 | if len(commit.Parents) > 0 { | 55 | if len(commit.Parents) > 0 { | ||
| 56 | base = commit.Parents[0].String() | 56 | base = commit.Parents[0].String() | ||
| 57 | } | 57 | } | ||
| 58 | - | ||||
| 59 | detail := domain.CommitDetail{Commit: commit} | 58 | detail := domain.CommitDetail{Commit: commit} | ||
| 59 | + | detail.Stats, detail.Diff, err = g.diffTrees(ctx, dir, base, commit.ID.String(), limits) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return domain.CommitDetail{}, fmt.Errorf("diff of %s in %q: %w", commit.ID, repo, err) | |||
| 62 | + | } | |||
| 63 | + | return detail, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // diffTrees reads the diff from base to head, two commit ids or tree ids. | |||
| 67 | + | func (g *Git) diffTrees(ctx context.Context, dir, base, head string, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) { | |||
| 68 | + | var ( | |||
| 69 | + | diff domain.Diff | |||
| 70 | + | stats domain.DiffStats | |||
| 71 | + | ) | |||
| 60 | args := readArgs(dir, "diff-tree", "-r", "-M", "-z", "--raw", "--numstat", "-p", "-U3", | 72 | args := readArgs(dir, "diff-tree", "-r", "-M", "-z", "--raw", "--numstat", "-p", "-U3", | ||
| 61 | - | "--no-ext-diff", "--no-textconv", "--no-color", "--end-of-options", base, commit.ID.String()) | 73 | + | "--no-ext-diff", "--no-textconv", "--no-color", "--end-of-options", base, head) |
| 62 | - | err = g.pipe(ctx, args, func(r *bufio.Reader) error { | 74 | + | err := g.pipe(ctx, args, func(r *bufio.Reader) error { |
| 63 | var err error | 75 | var err error | ||
| 64 | - | detail.Diff, detail.Stats, err = readDiff(r, limits) | 76 | + | diff, stats, err = readDiff(r, limits) |
| 65 | return err | 77 | return err | ||
| 66 | }) | 78 | }) | ||
| 67 | if err != nil { | 79 | if err != nil { | ||
| 68 | - | return domain.CommitDetail{}, fmt.Errorf("diff of %s in %q: %w", commit.ID, repo, err) | 80 | + | return domain.DiffStats{}, domain.Diff{}, err |
| 69 | } | 81 | } | ||
| 70 | - | return detail, nil | 82 | + | return stats, diff, nil |
| 71 | } | 83 | } | ||
| 72 | 84 | ||||
| 73 | // rawEntry is one file of the diff: a `--raw` record and its `--numstat`. | 85 | // rawEntry is one file of the diff: a `--raw` record and its `--numstat`. | ||
M internal/adapters/gitcli/git.go +7 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "context" | |
| 11 | 11 | "errors" | |
| 12 | 12 | "fmt" | |
| 13 | + | "io" | |
| 13 | 14 | "os/exec" | |
| 14 | 15 | "path/filepath" | |
| 15 | 16 | "runtime" | |
| @@ -122,6 +123,11 @@ func (g *Git) acquireExec(ctx context.Context) (release func(), err error) { | |||
| 122 | 123 | ||
| 123 | 124 | // run executes git with args in the isolated environment and returns stdout. | |
| 124 | 125 | func (g *Git) run(ctx context.Context, args ...string) (string, error) { | |
| 126 | + | return g.runInput(ctx, nil, args...) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // runInput is run with the given standard input; nil means none. | |
| 130 | + | func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) { | |
| 125 | 131 | if _, ok := ctx.Deadline(); !ok { | |
| 126 | 132 | var cancel context.CancelFunc | |
| 127 | 133 | ctx, cancel = context.WithTimeout(ctx, commandTimeout) | |
| @@ -136,6 +142,7 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) { | |||
| 136 | 142 | cmd.Env = g.env | |
| 137 | 143 | cmd.Dir = g.dataDir | |
| 138 | 144 | cmd.WaitDelay = 5 * time.Second | |
| 145 | + | cmd.Stdin = stdin | |
| 139 | 146 | var stdout, stderr bytes.Buffer | |
| 140 | 147 | cmd.Stdout = &stdout | |
| 141 | 148 | cmd.Stderr = &stderr | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "context" | 10 | "context" | ||
| 11 | "errors" | 11 | "errors" | ||
| 12 | "fmt" | 12 | "fmt" | ||
| 13 | + | "io" | |||
| 13 | "os/exec" | 14 | "os/exec" | ||
| 14 | "path/filepath" | 15 | "path/filepath" | ||
| 15 | "runtime" | 16 | "runtime" | ||
| @@ -122,6 +123,11 @@ func (g *Git) acquireExec(ctx context.Context) (release func(), err error) { | |||||
| 122 | 123 | ||||
| 123 | // run executes git with args in the isolated environment and returns stdout. | 124 | // run executes git with args in the isolated environment and returns stdout. | ||
| 124 | func (g *Git) run(ctx context.Context, args ...string) (string, error) { | 125 | func (g *Git) run(ctx context.Context, args ...string) (string, error) { | ||
| 126 | + | return g.runInput(ctx, nil, args...) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // runInput is run with the given standard input; nil means none. | |||
| 130 | + | func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) { | |||
| 125 | if _, ok := ctx.Deadline(); !ok { | 131 | if _, ok := ctx.Deadline(); !ok { | ||
| 126 | var cancel context.CancelFunc | 132 | var cancel context.CancelFunc | ||
| 127 | ctx, cancel = context.WithTimeout(ctx, commandTimeout) | 133 | ctx, cancel = context.WithTimeout(ctx, commandTimeout) | ||
| @@ -136,6 +142,7 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) { | |||||
| 136 | cmd.Env = g.env | 142 | cmd.Env = g.env | ||
| 137 | cmd.Dir = g.dataDir | 143 | cmd.Dir = g.dataDir | ||
| 138 | cmd.WaitDelay = 5 * time.Second | 144 | cmd.WaitDelay = 5 * time.Second | ||
| 145 | + | cmd.Stdin = stdin | |||
| 139 | var stdout, stderr bytes.Buffer | 146 | var stdout, stderr bytes.Buffer | ||
| 140 | cmd.Stdout = &stdout | 147 | cmd.Stdout = &stdout | ||
| 141 | cmd.Stderr = &stderr | 148 | cmd.Stderr = &stderr | ||
A internal/adapters/gitcli/merge.go +237 -0
| @@ -0,0 +1,237 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "bytes" | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "os/exec" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var _ ports.MergeGit = (*Git)(nil) | |
| 21 | + | ||
| 22 | + | // exitCode returns the exit status of a failed git run, or -1 when err is not | |
| 23 | + | // the exit of the process (it did not start, was cancelled, ...). | |
| 24 | + | func exitCode(err error) int { | |
| 25 | + | var exit *exec.ExitError | |
| 26 | + | if errors.As(err, &exit) { | |
| 27 | + | return exit.ExitCode() | |
| 28 | + | } | |
| 29 | + | return -1 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // parseID parses the id git printed on a line. | |
| 33 | + | func parseID(out string) (domain.ObjectID, error) { | |
| 34 | + | id, err := domain.ParseObjectID(strings.TrimSpace(out)) | |
| 35 | + | if err != nil { | |
| 36 | + | return domain.ObjectID{}, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err) | |
| 37 | + | } | |
| 38 | + | return id, nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`. | |
| 42 | + | // | |
| 43 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps a branch name to a git object name | |
| 44 | + | func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) { | |
| 45 | + | dir, err := g.repoDir(repo) | |
| 46 | + | if err != nil { | |
| 47 | + | return domain.ObjectID{}, err | |
| 48 | + | } | |
| 49 | + | if _, err := domain.ParseBranchName(branch); err != nil { | |
| 50 | + | return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound) | |
| 51 | + | } | |
| 52 | + | var id domain.ObjectID | |
| 53 | + | err = g.cat.do(ctx, dir, func(c *catConn) error { | |
| 54 | + | h, err := c.info(headsRefPrefix + branch + "^{commit}") | |
| 55 | + | if err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | id, err = domain.ParseObjectID(h.ID) | |
| 59 | + | if err != nil { | |
| 60 | + | return c.fail(err) | |
| 61 | + | } | |
| 62 | + | return nil | |
| 63 | + | }) | |
| 64 | + | if notFound(err) { | |
| 65 | + | return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound) | |
| 66 | + | } | |
| 67 | + | if err != nil { | |
| 68 | + | return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, err) | |
| 69 | + | } | |
| 70 | + | return id, nil | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // HasCommit asks `cat-file` for the type of the object. | |
| 74 | + | func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error) { | |
| 75 | + | dir, err := g.repoDir(repo) | |
| 76 | + | if err != nil { | |
| 77 | + | return false, err | |
| 78 | + | } | |
| 79 | + | found := false | |
| 80 | + | err = g.cat.do(ctx, dir, func(c *catConn) error { | |
| 81 | + | h, err := c.info(id.String()) | |
| 82 | + | if notFound(err) { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | if err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | found = h.Type == "commit" | |
| 89 | + | return nil | |
| 90 | + | }) | |
| 91 | + | if err != nil { | |
| 92 | + | return false, fmt.Errorf("look up commit %s in %q: %w", id, repo, err) | |
| 93 | + | } | |
| 94 | + | return found, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // MergeBase runs `git merge-base`, which exits 1 when there is none. | |
| 98 | + | // | |
| 99 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the domain not found error | |
| 100 | + | func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) { | |
| 101 | + | dir, err := g.repoDir(repo) | |
| 102 | + | if err != nil { | |
| 103 | + | return domain.ObjectID{}, err | |
| 104 | + | } | |
| 105 | + | out, err := g.run(ctx, readArgs(dir, "merge-base", a.String(), b.String())...) | |
| 106 | + | switch { | |
| 107 | + | case exitCode(err) == 1: | |
| 108 | + | return domain.ObjectID{}, fmt.Errorf("%s and %s share no history: %w", a, b, domain.ErrNotFound) | |
| 109 | + | case err != nil: | |
| 110 | + | return domain.ObjectID{}, fmt.Errorf("merge base in %q: %w", repo, err) | |
| 111 | + | } | |
| 112 | + | return parseID(out) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // IsAncestor runs `git merge-base --is-ancestor`, which exits 1 for "no". | |
| 116 | + | func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error) { | |
| 117 | + | dir, err := g.repoDir(repo) | |
| 118 | + | if err != nil { | |
| 119 | + | return false, err | |
| 120 | + | } | |
| 121 | + | _, err = g.run(ctx, readArgs(dir, "merge-base", "--is-ancestor", ancestor.String(), descendant.String())...) | |
| 122 | + | switch { | |
| 123 | + | case err == nil: | |
| 124 | + | return true, nil | |
| 125 | + | case exitCode(err) == 1: | |
| 126 | + | return false, nil | |
| 127 | + | } | |
| 128 | + | return false, fmt.Errorf("ancestry in %q: %w", repo, err) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // ListRange runs `git log <head> ^<exclude>`. | |
| 132 | + | // | |
| 133 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |
| 134 | + | func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | |
| 135 | + | if limit < 1 || limit > ports.MaxCommitLimit { | |
| 136 | + | return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit) | |
| 137 | + | } | |
| 138 | + | dir, err := g.repoDir(repo) | |
| 139 | + | if err != nil { | |
| 140 | + | return nil, false, err | |
| 141 | + | } | |
| 142 | + | args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat, "--end-of-options", head.String()) | |
| 143 | + | if !exclude.IsZero() { | |
| 144 | + | args = append(args, "^"+exclude.String()) | |
| 145 | + | } | |
| 146 | + | var ( | |
| 147 | + | commits []domain.Commit | |
| 148 | + | more bool | |
| 149 | + | ) | |
| 150 | + | err = g.pipe(ctx, args, func(r *bufio.Reader) error { | |
| 151 | + | for { | |
| 152 | + | c, err := readCommit(r) | |
| 153 | + | if errors.Is(err, io.EOF) { | |
| 154 | + | return nil | |
| 155 | + | } | |
| 156 | + | if err != nil { | |
| 157 | + | return err | |
| 158 | + | } | |
| 159 | + | if len(commits) == limit { | |
| 160 | + | more = true | |
| 161 | + | return errStop | |
| 162 | + | } | |
| 163 | + | commits = append(commits, c) | |
| 164 | + | } | |
| 165 | + | }) | |
| 166 | + | if err != nil { | |
| 167 | + | return nil, false, fmt.Errorf("commits of %s in %q: %w", head, repo, err) | |
| 168 | + | } | |
| 169 | + | return commits, more, nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // DiffCommits reads the diff between two commits like GetCommit does. | |
| 173 | + | // | |
| 174 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in | |
| 175 | + | func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) { | |
| 176 | + | dir, err := g.repoDir(repo) | |
| 177 | + | if err != nil { | |
| 178 | + | return domain.DiffStats{}, domain.Diff{}, err | |
| 179 | + | } | |
| 180 | + | stats, diff, err := g.diffTrees(ctx, dir, base.String(), head.String(), limits) | |
| 181 | + | if err != nil { | |
| 182 | + | return domain.DiffStats{}, domain.Diff{}, fmt.Errorf("diff %s..%s in %q: %w", base, head, repo, err) | |
| 183 | + | } | |
| 184 | + | return stats, diff, nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // CommitTree runs `git rev-parse <commit>^{tree}`. | |
| 188 | + | // | |
| 189 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed | |
| 190 | + | func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) { | |
| 191 | + | dir, err := g.repoDir(repo) | |
| 192 | + | if err != nil { | |
| 193 | + | return domain.ObjectID{}, err | |
| 194 | + | } | |
| 195 | + | out, err := g.run(ctx, readArgs(dir, "rev-parse", "--verify", id.String()+"^{tree}")...) | |
| 196 | + | if err != nil { | |
| 197 | + | return domain.ObjectID{}, fmt.Errorf("tree of %s in %q: %w", id, repo, err) | |
| 198 | + | } | |
| 199 | + | return parseID(out) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // WriteCommit runs `git hash-object -t commit -w --stdin`, which also checks | |
| 203 | + | // that the object is a well-formed commit. | |
| 204 | + | func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error) { | |
| 205 | + | dir, err := g.repoDir(repo) | |
| 206 | + | if err != nil { | |
| 207 | + | return domain.ObjectID{}, err | |
| 208 | + | } | |
| 209 | + | out, err := g.runInput(ctx, bytes.NewReader(object), "--git-dir="+dir, "hash-object", "-t", "commit", "-w", "--stdin") | |
| 210 | + | if err != nil { | |
| 211 | + | return domain.ObjectID{}, fmt.Errorf("write commit in %q: %w", repo, err) | |
| 212 | + | } | |
| 213 | + | return parseID(out) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which | |
| 217 | + | // takes the ref lock and refuses when the ref is not at old. A failure is | |
| 218 | + | // told from a moved branch by reading the branch again. | |
| 219 | + | // | |
| 220 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port | |
| 221 | + | func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error { | |
| 222 | + | dir, err := g.repoDir(repo) | |
| 223 | + | if err != nil { | |
| 224 | + | return err | |
| 225 | + | } | |
| 226 | + | if _, err := domain.ParseBranchName(branch); err != nil { | |
| 227 | + | return fmt.Errorf("update branch of %q: %w", repo, err) | |
| 228 | + | } | |
| 229 | + | _, err = g.run(ctx, "--git-dir="+dir, "update-ref", headsRefPrefix+branch, newID.String(), old.String()) | |
| 230 | + | if err == nil { | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | if cur, herr := g.BranchHead(ctx, repo, branch); herr == nil && cur != old { | |
| 234 | + | return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict) | |
| 235 | + | } | |
| 236 | + | return fmt.Errorf("update branch %q of %q: %w", branch, repo, err) | |
| 237 | + | } | |
| @@ -0,0 +1,237 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitcli | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "bytes" | |||
| 8 | + | "context" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var _ ports.MergeGit = (*Git)(nil) | |||
| 21 | + | ||||
| 22 | + | // exitCode returns the exit status of a failed git run, or -1 when err is not | |||
| 23 | + | // the exit of the process (it did not start, was cancelled, ...). | |||
| 24 | + | func exitCode(err error) int { | |||
| 25 | + | var exit *exec.ExitError | |||
| 26 | + | if errors.As(err, &exit) { | |||
| 27 | + | return exit.ExitCode() | |||
| 28 | + | } | |||
| 29 | + | return -1 | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // parseID parses the id git printed on a line. | |||
| 33 | + | func parseID(out string) (domain.ObjectID, error) { | |||
| 34 | + | id, err := domain.ParseObjectID(strings.TrimSpace(out)) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return domain.ObjectID{}, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err) | |||
| 37 | + | } | |||
| 38 | + | return id, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`. | |||
| 42 | + | // | |||
| 43 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps a branch name to a git object name | |||
| 44 | + | func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) { | |||
| 45 | + | dir, err := g.repoDir(repo) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return domain.ObjectID{}, err | |||
| 48 | + | } | |||
| 49 | + | if _, err := domain.ParseBranchName(branch); err != nil { | |||
| 50 | + | return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound) | |||
| 51 | + | } | |||
| 52 | + | var id domain.ObjectID | |||
| 53 | + | err = g.cat.do(ctx, dir, func(c *catConn) error { | |||
| 54 | + | h, err := c.info(headsRefPrefix + branch + "^{commit}") | |||
| 55 | + | if err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | id, err = domain.ParseObjectID(h.ID) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return c.fail(err) | |||
| 61 | + | } | |||
| 62 | + | return nil | |||
| 63 | + | }) | |||
| 64 | + | if notFound(err) { | |||
| 65 | + | return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound) | |||
| 66 | + | } | |||
| 67 | + | if err != nil { | |||
| 68 | + | return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, err) | |||
| 69 | + | } | |||
| 70 | + | return id, nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // HasCommit asks `cat-file` for the type of the object. | |||
| 74 | + | func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error) { | |||
| 75 | + | dir, err := g.repoDir(repo) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return false, err | |||
| 78 | + | } | |||
| 79 | + | found := false | |||
| 80 | + | err = g.cat.do(ctx, dir, func(c *catConn) error { | |||
| 81 | + | h, err := c.info(id.String()) | |||
| 82 | + | if notFound(err) { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | if err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | found = h.Type == "commit" | |||
| 89 | + | return nil | |||
| 90 | + | }) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return false, fmt.Errorf("look up commit %s in %q: %w", id, repo, err) | |||
| 93 | + | } | |||
| 94 | + | return found, nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // MergeBase runs `git merge-base`, which exits 1 when there is none. | |||
| 98 | + | // | |||
| 99 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the domain not found error | |||
| 100 | + | func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) { | |||
| 101 | + | dir, err := g.repoDir(repo) | |||
| 102 | + | if err != nil { | |||
| 103 | + | return domain.ObjectID{}, err | |||
| 104 | + | } | |||
| 105 | + | out, err := g.run(ctx, readArgs(dir, "merge-base", a.String(), b.String())...) | |||
| 106 | + | switch { | |||
| 107 | + | case exitCode(err) == 1: | |||
| 108 | + | return domain.ObjectID{}, fmt.Errorf("%s and %s share no history: %w", a, b, domain.ErrNotFound) | |||
| 109 | + | case err != nil: | |||
| 110 | + | return domain.ObjectID{}, fmt.Errorf("merge base in %q: %w", repo, err) | |||
| 111 | + | } | |||
| 112 | + | return parseID(out) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // IsAncestor runs `git merge-base --is-ancestor`, which exits 1 for "no". | |||
| 116 | + | func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error) { | |||
| 117 | + | dir, err := g.repoDir(repo) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return false, err | |||
| 120 | + | } | |||
| 121 | + | _, err = g.run(ctx, readArgs(dir, "merge-base", "--is-ancestor", ancestor.String(), descendant.String())...) | |||
| 122 | + | switch { | |||
| 123 | + | case err == nil: | |||
| 124 | + | return true, nil | |||
| 125 | + | case exitCode(err) == 1: | |||
| 126 | + | return false, nil | |||
| 127 | + | } | |||
| 128 | + | return false, fmt.Errorf("ancestry in %q: %w", repo, err) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // ListRange runs `git log <head> ^<exclude>`. | |||
| 132 | + | // | |||
| 133 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |||
| 134 | + | func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | |||
| 135 | + | if limit < 1 || limit > ports.MaxCommitLimit { | |||
| 136 | + | return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit) | |||
| 137 | + | } | |||
| 138 | + | dir, err := g.repoDir(repo) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return nil, false, err | |||
| 141 | + | } | |||
| 142 | + | args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat, "--end-of-options", head.String()) | |||
| 143 | + | if !exclude.IsZero() { | |||
| 144 | + | args = append(args, "^"+exclude.String()) | |||
| 145 | + | } | |||
| 146 | + | var ( | |||
| 147 | + | commits []domain.Commit | |||
| 148 | + | more bool | |||
| 149 | + | ) | |||
| 150 | + | err = g.pipe(ctx, args, func(r *bufio.Reader) error { | |||
| 151 | + | for { | |||
| 152 | + | c, err := readCommit(r) | |||
| 153 | + | if errors.Is(err, io.EOF) { | |||
| 154 | + | return nil | |||
| 155 | + | } | |||
| 156 | + | if err != nil { | |||
| 157 | + | return err | |||
| 158 | + | } | |||
| 159 | + | if len(commits) == limit { | |||
| 160 | + | more = true | |||
| 161 | + | return errStop | |||
| 162 | + | } | |||
| 163 | + | commits = append(commits, c) | |||
| 164 | + | } | |||
| 165 | + | }) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return nil, false, fmt.Errorf("commits of %s in %q: %w", head, repo, err) | |||
| 168 | + | } | |||
| 169 | + | return commits, more, nil | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // DiffCommits reads the diff between two commits like GetCommit does. | |||
| 173 | + | // | |||
| 174 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in | |||
| 175 | + | func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) { | |||
| 176 | + | dir, err := g.repoDir(repo) | |||
| 177 | + | if err != nil { | |||
| 178 | + | return domain.DiffStats{}, domain.Diff{}, err | |||
| 179 | + | } | |||
| 180 | + | stats, diff, err := g.diffTrees(ctx, dir, base.String(), head.String(), limits) | |||
| 181 | + | if err != nil { | |||
| 182 | + | return domain.DiffStats{}, domain.Diff{}, fmt.Errorf("diff %s..%s in %q: %w", base, head, repo, err) | |||
| 183 | + | } | |||
| 184 | + | return stats, diff, nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // CommitTree runs `git rev-parse <commit>^{tree}`. | |||
| 188 | + | // | |||
| 189 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed | |||
| 190 | + | func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) { | |||
| 191 | + | dir, err := g.repoDir(repo) | |||
| 192 | + | if err != nil { | |||
| 193 | + | return domain.ObjectID{}, err | |||
| 194 | + | } | |||
| 195 | + | out, err := g.run(ctx, readArgs(dir, "rev-parse", "--verify", id.String()+"^{tree}")...) | |||
| 196 | + | if err != nil { | |||
| 197 | + | return domain.ObjectID{}, fmt.Errorf("tree of %s in %q: %w", id, repo, err) | |||
| 198 | + | } | |||
| 199 | + | return parseID(out) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // WriteCommit runs `git hash-object -t commit -w --stdin`, which also checks | |||
| 203 | + | // that the object is a well-formed commit. | |||
| 204 | + | func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error) { | |||
| 205 | + | dir, err := g.repoDir(repo) | |||
| 206 | + | if err != nil { | |||
| 207 | + | return domain.ObjectID{}, err | |||
| 208 | + | } | |||
| 209 | + | out, err := g.runInput(ctx, bytes.NewReader(object), "--git-dir="+dir, "hash-object", "-t", "commit", "-w", "--stdin") | |||
| 210 | + | if err != nil { | |||
| 211 | + | return domain.ObjectID{}, fmt.Errorf("write commit in %q: %w", repo, err) | |||
| 212 | + | } | |||
| 213 | + | return parseID(out) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which | |||
| 217 | + | // takes the ref lock and refuses when the ref is not at old. A failure is | |||
| 218 | + | // told from a moved branch by reading the branch again. | |||
| 219 | + | // | |||
| 220 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port | |||
| 221 | + | func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error { | |||
| 222 | + | dir, err := g.repoDir(repo) | |||
| 223 | + | if err != nil { | |||
| 224 | + | return err | |||
| 225 | + | } | |||
| 226 | + | if _, err := domain.ParseBranchName(branch); err != nil { | |||
| 227 | + | return fmt.Errorf("update branch of %q: %w", repo, err) | |||
| 228 | + | } | |||
| 229 | + | _, err = g.run(ctx, "--git-dir="+dir, "update-ref", headsRefPrefix+branch, newID.String(), old.String()) | |||
| 230 | + | if err == nil { | |||
| 231 | + | return nil | |||
| 232 | + | } | |||
| 233 | + | if cur, herr := g.BranchHead(ctx, repo, branch); herr == nil && cur != old { | |||
| 234 | + | return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict) | |||
| 235 | + | } | |||
| 236 | + | return fmt.Errorf("update branch %q of %q: %w", branch, repo, err) | |||
| 237 | + | } | |||
A internal/adapters/gitcli/merge_test.go +207 -0
| @@ -0,0 +1,207 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func oidOf(t *testing.T, s string) domain.ObjectID { | |
| 17 | + | t.Helper() | |
| 18 | + | id, err := domain.ParseObjectID(s) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | return id | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat. | |
| 24 | + | type mergeFixture struct { | |
| 25 | + | f *fixtureRepo | |
| 26 | + | a, b, c, d domain.ObjectID | |
| 27 | + | mainTip, featTip domain.ObjectID | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func newMergeFixture(t *testing.T, data string) mergeFixture { | |
| 31 | + | t.Helper() | |
| 32 | + | f := newFixtureRepo(t, data, "proj") | |
| 33 | + | f.write("a.txt", "one\ntwo\nthree\n") | |
| 34 | + | a := f.commit("add a") | |
| 35 | + | f.write("b.txt", "bee\n") | |
| 36 | + | b := f.commit("add b") | |
| 37 | + | f.git("update-ref", "refs/heads/main", b) | |
| 38 | + | f.checkoutNew("feat") | |
| 39 | + | f.write("a.txt", "one\n2\nthree\n") | |
| 40 | + | c := f.commit("change a") | |
| 41 | + | f.write("c.txt", "see\n") | |
| 42 | + | d := f.commit("add c") | |
| 43 | + | f.git("update-ref", "refs/heads/feat", d) | |
| 44 | + | return mergeFixture{ | |
| 45 | + | f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d), | |
| 46 | + | mainTip: oidOf(t, b), featTip: oidOf(t, d), | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestBranchHeadAndHasCommit(t *testing.T) { | |
| 51 | + | g, data := newGit(t) | |
| 52 | + | ctx := context.Background() | |
| 53 | + | m := newMergeFixture(t, data) | |
| 54 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 55 | + | ||
| 56 | + | got, err := g.BranchHead(ctx, m.f.name, "feat") | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | assert.Equal(t, m.d, got) | |
| 59 | + | ||
| 60 | + | for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} { | |
| 61 | + | _, err = g.BranchHead(ctx, m.f.name, branch) | |
| 62 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch) | |
| 63 | + | } | |
| 64 | + | _, err = g.BranchHead(ctx, name(t, "missing"), "main") | |
| 65 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 66 | + | ||
| 67 | + | ok, err := g.HasCommit(ctx, m.f.name, m.c) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | assert.True(t, ok) | |
| 70 | + | ok, err = g.HasCommit(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40))) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | assert.False(t, ok) | |
| 73 | + | // A tree is not a commit. | |
| 74 | + | tree, err := g.CommitTree(ctx, m.f.name, m.d) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | ok, err = g.HasCommit(ctx, m.f.name, tree) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | assert.False(t, ok) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestMergeBaseAndAncestry(t *testing.T) { | |
| 82 | + | g, data := newGit(t) | |
| 83 | + | ctx := context.Background() | |
| 84 | + | m := newMergeFixture(t, data) | |
| 85 | + | // A branch that diverged from a. | |
| 86 | + | m.f.git("checkout", "--quiet", "-b", "side", m.a.String()) | |
| 87 | + | m.f.write("s.txt", "side\n") | |
| 88 | + | side := oidOf(t, m.f.commit("side")) | |
| 89 | + | m.f.git("update-ref", "refs/heads/side", side.String()) | |
| 90 | + | // An unrelated root. | |
| 91 | + | m.f.git("checkout", "--quiet", "--orphan", "other") | |
| 92 | + | m.f.git("rm", "-rf", "--quiet", ".") | |
| 93 | + | m.f.write("o.txt", "other\n") | |
| 94 | + | other := oidOf(t, m.f.commit("other root")) | |
| 95 | + | ||
| 96 | + | base, err := g.MergeBase(ctx, m.f.name, m.mainTip, m.featTip) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base") | |
| 99 | + | base, err = g.MergeBase(ctx, m.f.name, side, m.featTip) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.Equal(t, m.a, base) | |
| 102 | + | _, err = g.MergeBase(ctx, m.f.name, other, m.featTip) | |
| 103 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history") | |
| 104 | + | ||
| 105 | + | for _, tc := range []struct { | |
| 106 | + | anc, desc domain.ObjectID | |
| 107 | + | want bool | |
| 108 | + | }{ | |
| 109 | + | {m.mainTip, m.featTip, true}, | |
| 110 | + | {m.featTip, m.mainTip, false}, | |
| 111 | + | {m.featTip, m.featTip, true}, | |
| 112 | + | {side, m.featTip, false}, | |
| 113 | + | {other, m.featTip, false}, | |
| 114 | + | } { | |
| 115 | + | got, err := g.IsAncestor(ctx, m.f.name, tc.anc, tc.desc) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc) | |
| 118 | + | } | |
| 119 | + | _, err = g.IsAncestor(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40)), m.featTip) | |
| 120 | + | assert.Error(t, err, "an unknown commit is an error, not a plain no") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestListRange(t *testing.T) { | |
| 124 | + | g, data := newGit(t) | |
| 125 | + | ctx := context.Background() | |
| 126 | + | m := newMergeFixture(t, data) | |
| 127 | + | ||
| 128 | + | commits, more, err := g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 10) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | assert.False(t, more) | |
| 131 | + | require.Len(t, commits, 2) | |
| 132 | + | assert.Equal(t, m.d, commits[0].ID, "newest first") | |
| 133 | + | assert.Equal(t, m.c, commits[1].ID) | |
| 134 | + | assert.Equal(t, "add c", commits[0].Summary) | |
| 135 | + | ||
| 136 | + | commits, more, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 1) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | assert.True(t, more) | |
| 139 | + | require.Len(t, commits, 1) | |
| 140 | + | ||
| 141 | + | commits, more, err = g.ListRange(ctx, m.f.name, domain.ObjectID{}, m.featTip, 10) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | assert.False(t, more) | |
| 144 | + | assert.Len(t, commits, 4, "a zero exclude lists the whole history") | |
| 145 | + | ||
| 146 | + | commits, _, err = g.ListRange(ctx, m.f.name, m.featTip, m.mainTip, 10) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | assert.Empty(t, commits) | |
| 149 | + | ||
| 150 | + | _, _, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 0) | |
| 151 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestDiffCommits(t *testing.T) { | |
| 155 | + | g, data := newGit(t) | |
| 156 | + | ctx := context.Background() | |
| 157 | + | m := newMergeFixture(t, data) | |
| 158 | + | ||
| 159 | + | stats, diff, err := g.DiffCommits(ctx, m.f.name, m.mainTip, m.featTip, domain.DefaultDiffLimits()) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats) | |
| 162 | + | require.Len(t, diff.Files, 2) | |
| 163 | + | assert.Equal(t, "a.txt", diff.Files[0].NewPath) | |
| 164 | + | assert.Equal(t, "c.txt", diff.Files[1].NewPath) | |
| 165 | + | assert.True(t, diff.HasLine("a.txt", domain.SideNew, 2)) | |
| 166 | + | assert.True(t, diff.HasLine("a.txt", domain.SideOld, 2)) | |
| 167 | + | assert.True(t, diff.HasLine("c.txt", domain.SideNew, 1)) | |
| 168 | + | assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1)) | |
| 169 | + | ||
| 170 | + | // The diff between two commits of a branch. | |
| 171 | + | stats, _, err = g.DiffCommits(ctx, m.f.name, m.c, m.featTip, domain.DefaultDiffLimits()) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestWriteCommitAndUpdateBranch(t *testing.T) { | |
| 177 | + | g, data := newGit(t) | |
| 178 | + | ctx := context.Background() | |
| 179 | + | m := newMergeFixture(t, data) | |
| 180 | + | ||
| 181 | + | tree, err := g.CommitTree(ctx, m.f.name, m.featTip) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | assert.Equal(t, m.f.git("rev-parse", m.featTip.String()+"^{tree}"), tree.String()) | |
| 184 | + | ||
| 185 | + | obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" + | |
| 186 | + | "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n" | |
| 187 | + | id, err := g.WriteCommit(ctx, m.f.name, []byte(obj)) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | assert.Equal(t, "commit", m.f.git("cat-file", "-t", id.String())) | |
| 190 | + | assert.Equal(t, "squashed", m.f.git("log", "-1", "--format=%s", id.String())) | |
| 191 | + | ||
| 192 | + | _, err = g.WriteCommit(ctx, m.f.name, []byte("not a commit")) | |
| 193 | + | assert.Error(t, err, "git checks the object") | |
| 194 | + | ||
| 195 | + | // The old value is a precondition. | |
| 196 | + | err = g.UpdateBranch(ctx, m.f.name, "main", m.featTip, id) | |
| 197 | + | assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat") | |
| 198 | + | assert.Equal(t, m.mainTip.String(), m.f.git("rev-parse", "refs/heads/main")) | |
| 199 | + | ||
| 200 | + | require.NoError(t, g.UpdateBranch(ctx, m.f.name, "main", m.mainTip, id)) | |
| 201 | + | assert.Equal(t, id.String(), m.f.git("rev-parse", "refs/heads/main")) | |
| 202 | + | ||
| 203 | + | err = g.UpdateBranch(ctx, m.f.name, "refs/heads/main", id, m.mainTip) | |
| 204 | + | assert.Error(t, err, "only short branch names") | |
| 205 | + | err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip) | |
| 206 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 207 | + | } | |
| @@ -0,0 +1,207 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitcli_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func oidOf(t *testing.T, s string) domain.ObjectID { | |||
| 17 | + | t.Helper() | |||
| 18 | + | id, err := domain.ParseObjectID(s) | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | return id | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat. | |||
| 24 | + | type mergeFixture struct { | |||
| 25 | + | f *fixtureRepo | |||
| 26 | + | a, b, c, d domain.ObjectID | |||
| 27 | + | mainTip, featTip domain.ObjectID | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func newMergeFixture(t *testing.T, data string) mergeFixture { | |||
| 31 | + | t.Helper() | |||
| 32 | + | f := newFixtureRepo(t, data, "proj") | |||
| 33 | + | f.write("a.txt", "one\ntwo\nthree\n") | |||
| 34 | + | a := f.commit("add a") | |||
| 35 | + | f.write("b.txt", "bee\n") | |||
| 36 | + | b := f.commit("add b") | |||
| 37 | + | f.git("update-ref", "refs/heads/main", b) | |||
| 38 | + | f.checkoutNew("feat") | |||
| 39 | + | f.write("a.txt", "one\n2\nthree\n") | |||
| 40 | + | c := f.commit("change a") | |||
| 41 | + | f.write("c.txt", "see\n") | |||
| 42 | + | d := f.commit("add c") | |||
| 43 | + | f.git("update-ref", "refs/heads/feat", d) | |||
| 44 | + | return mergeFixture{ | |||
| 45 | + | f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d), | |||
| 46 | + | mainTip: oidOf(t, b), featTip: oidOf(t, d), | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestBranchHeadAndHasCommit(t *testing.T) { | |||
| 51 | + | g, data := newGit(t) | |||
| 52 | + | ctx := context.Background() | |||
| 53 | + | m := newMergeFixture(t, data) | |||
| 54 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 55 | + | ||||
| 56 | + | got, err := g.BranchHead(ctx, m.f.name, "feat") | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | assert.Equal(t, m.d, got) | |||
| 59 | + | ||||
| 60 | + | for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} { | |||
| 61 | + | _, err = g.BranchHead(ctx, m.f.name, branch) | |||
| 62 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch) | |||
| 63 | + | } | |||
| 64 | + | _, err = g.BranchHead(ctx, name(t, "missing"), "main") | |||
| 65 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 66 | + | ||||
| 67 | + | ok, err := g.HasCommit(ctx, m.f.name, m.c) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | assert.True(t, ok) | |||
| 70 | + | ok, err = g.HasCommit(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40))) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | assert.False(t, ok) | |||
| 73 | + | // A tree is not a commit. | |||
| 74 | + | tree, err := g.CommitTree(ctx, m.f.name, m.d) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | ok, err = g.HasCommit(ctx, m.f.name, tree) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | assert.False(t, ok) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestMergeBaseAndAncestry(t *testing.T) { | |||
| 82 | + | g, data := newGit(t) | |||
| 83 | + | ctx := context.Background() | |||
| 84 | + | m := newMergeFixture(t, data) | |||
| 85 | + | // A branch that diverged from a. | |||
| 86 | + | m.f.git("checkout", "--quiet", "-b", "side", m.a.String()) | |||
| 87 | + | m.f.write("s.txt", "side\n") | |||
| 88 | + | side := oidOf(t, m.f.commit("side")) | |||
| 89 | + | m.f.git("update-ref", "refs/heads/side", side.String()) | |||
| 90 | + | // An unrelated root. | |||
| 91 | + | m.f.git("checkout", "--quiet", "--orphan", "other") | |||
| 92 | + | m.f.git("rm", "-rf", "--quiet", ".") | |||
| 93 | + | m.f.write("o.txt", "other\n") | |||
| 94 | + | other := oidOf(t, m.f.commit("other root")) | |||
| 95 | + | ||||
| 96 | + | base, err := g.MergeBase(ctx, m.f.name, m.mainTip, m.featTip) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base") | |||
| 99 | + | base, err = g.MergeBase(ctx, m.f.name, side, m.featTip) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | assert.Equal(t, m.a, base) | |||
| 102 | + | _, err = g.MergeBase(ctx, m.f.name, other, m.featTip) | |||
| 103 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history") | |||
| 104 | + | ||||
| 105 | + | for _, tc := range []struct { | |||
| 106 | + | anc, desc domain.ObjectID | |||
| 107 | + | want bool | |||
| 108 | + | }{ | |||
| 109 | + | {m.mainTip, m.featTip, true}, | |||
| 110 | + | {m.featTip, m.mainTip, false}, | |||
| 111 | + | {m.featTip, m.featTip, true}, | |||
| 112 | + | {side, m.featTip, false}, | |||
| 113 | + | {other, m.featTip, false}, | |||
| 114 | + | } { | |||
| 115 | + | got, err := g.IsAncestor(ctx, m.f.name, tc.anc, tc.desc) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc) | |||
| 118 | + | } | |||
| 119 | + | _, err = g.IsAncestor(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40)), m.featTip) | |||
| 120 | + | assert.Error(t, err, "an unknown commit is an error, not a plain no") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestListRange(t *testing.T) { | |||
| 124 | + | g, data := newGit(t) | |||
| 125 | + | ctx := context.Background() | |||
| 126 | + | m := newMergeFixture(t, data) | |||
| 127 | + | ||||
| 128 | + | commits, more, err := g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 10) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | assert.False(t, more) | |||
| 131 | + | require.Len(t, commits, 2) | |||
| 132 | + | assert.Equal(t, m.d, commits[0].ID, "newest first") | |||
| 133 | + | assert.Equal(t, m.c, commits[1].ID) | |||
| 134 | + | assert.Equal(t, "add c", commits[0].Summary) | |||
| 135 | + | ||||
| 136 | + | commits, more, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 1) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | assert.True(t, more) | |||
| 139 | + | require.Len(t, commits, 1) | |||
| 140 | + | ||||
| 141 | + | commits, more, err = g.ListRange(ctx, m.f.name, domain.ObjectID{}, m.featTip, 10) | |||
| 142 | + | require.NoError(t, err) | |||
| 143 | + | assert.False(t, more) | |||
| 144 | + | assert.Len(t, commits, 4, "a zero exclude lists the whole history") | |||
| 145 | + | ||||
| 146 | + | commits, _, err = g.ListRange(ctx, m.f.name, m.featTip, m.mainTip, 10) | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | assert.Empty(t, commits) | |||
| 149 | + | ||||
| 150 | + | _, _, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 0) | |||
| 151 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestDiffCommits(t *testing.T) { | |||
| 155 | + | g, data := newGit(t) | |||
| 156 | + | ctx := context.Background() | |||
| 157 | + | m := newMergeFixture(t, data) | |||
| 158 | + | ||||
| 159 | + | stats, diff, err := g.DiffCommits(ctx, m.f.name, m.mainTip, m.featTip, domain.DefaultDiffLimits()) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats) | |||
| 162 | + | require.Len(t, diff.Files, 2) | |||
| 163 | + | assert.Equal(t, "a.txt", diff.Files[0].NewPath) | |||
| 164 | + | assert.Equal(t, "c.txt", diff.Files[1].NewPath) | |||
| 165 | + | assert.True(t, diff.HasLine("a.txt", domain.SideNew, 2)) | |||
| 166 | + | assert.True(t, diff.HasLine("a.txt", domain.SideOld, 2)) | |||
| 167 | + | assert.True(t, diff.HasLine("c.txt", domain.SideNew, 1)) | |||
| 168 | + | assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1)) | |||
| 169 | + | ||||
| 170 | + | // The diff between two commits of a branch. | |||
| 171 | + | stats, _, err = g.DiffCommits(ctx, m.f.name, m.c, m.featTip, domain.DefaultDiffLimits()) | |||
| 172 | + | require.NoError(t, err) | |||
| 173 | + | assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestWriteCommitAndUpdateBranch(t *testing.T) { | |||
| 177 | + | g, data := newGit(t) | |||
| 178 | + | ctx := context.Background() | |||
| 179 | + | m := newMergeFixture(t, data) | |||
| 180 | + | ||||
| 181 | + | tree, err := g.CommitTree(ctx, m.f.name, m.featTip) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | assert.Equal(t, m.f.git("rev-parse", m.featTip.String()+"^{tree}"), tree.String()) | |||
| 184 | + | ||||
| 185 | + | obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" + | |||
| 186 | + | "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n" | |||
| 187 | + | id, err := g.WriteCommit(ctx, m.f.name, []byte(obj)) | |||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | assert.Equal(t, "commit", m.f.git("cat-file", "-t", id.String())) | |||
| 190 | + | assert.Equal(t, "squashed", m.f.git("log", "-1", "--format=%s", id.String())) | |||
| 191 | + | ||||
| 192 | + | _, err = g.WriteCommit(ctx, m.f.name, []byte("not a commit")) | |||
| 193 | + | assert.Error(t, err, "git checks the object") | |||
| 194 | + | ||||
| 195 | + | // The old value is a precondition. | |||
| 196 | + | err = g.UpdateBranch(ctx, m.f.name, "main", m.featTip, id) | |||
| 197 | + | assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat") | |||
| 198 | + | assert.Equal(t, m.mainTip.String(), m.f.git("rev-parse", "refs/heads/main")) | |||
| 199 | + | ||||
| 200 | + | require.NoError(t, g.UpdateBranch(ctx, m.f.name, "main", m.mainTip, id)) | |||
| 201 | + | assert.Equal(t, id.String(), m.f.git("rev-parse", "refs/heads/main")) | |||
| 202 | + | ||||
| 203 | + | err = g.UpdateBranch(ctx, m.f.name, "refs/heads/main", id, m.mainTip) | |||
| 204 | + | assert.Error(t, err, "only short branch names") | |||
| 205 | + | err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip) | |||
| 206 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 207 | + | } | |||