v0.13.0 dc42452

merge.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
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
20var _ 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, ...).
24func 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.
33func 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
44func (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.
74func (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
100func (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".
116func (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
134func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135	var excluded []string
136	if !exclude.IsZero() {
137		excluded = []string{"^" + exclude.String()}
138	}
139	return g.logCommits(ctx, repo, head, limit, nil, excluded)
140}
141
142// logCommits runs `git log` for the commits reachable from head: revArgs go
143// before the head (options such as --not), excluded after it. Newest first,
144// at most limit; more says there were others.
145//
146//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
147func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
148	if limit < 1 || limit > ports.MaxCommitLimit {
149		return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
150	}
151	dir, err := g.repoDir(repo)
152	if err != nil {
153		return nil, false, err
154	}
155	args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
156	args = append(args, revArgs...)
157	args = append(args, "--end-of-options", head.String())
158	args = append(args, excluded...)
159	var (
160		commits []domain.Commit
161		more    bool
162	)
163	err = g.pipe(ctx, args, func(r *bufio.Reader) error {
164		for {
165			c, err := readCommit(r)
166			if errors.Is(err, io.EOF) {
167				return nil
168			}
169			if err != nil {
170				return err
171			}
172			if len(commits) == limit {
173				more = true
174				return errStop
175			}
176			commits = append(commits, c)
177		}
178	})
179	if err != nil {
180		return nil, false, fmt.Errorf("commits of %s in %q: %w", head, repo, err)
181	}
182	return commits, more, nil
183}
184
185// DiffCommits reads the diff between two commits like GetCommit does.
186//
187//hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in
188func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
189	dir, err := g.repoDir(repo)
190	if err != nil {
191		return domain.DiffStats{}, domain.Diff{}, err
192	}
193	stats, diff, err := g.diffTrees(ctx, dir, base.String(), head.String(), limits)
194	if err != nil {
195		return domain.DiffStats{}, domain.Diff{}, fmt.Errorf("diff %s..%s in %q: %w", base, head, repo, err)
196	}
197	return stats, diff, nil
198}
199
200// CommitTree runs `git rev-parse <commit>^{tree}`.
201//
202//hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed
203func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {
204	dir, err := g.repoDir(repo)
205	if err != nil {
206		return domain.ObjectID{}, err
207	}
208	out, err := g.run(ctx, readArgs(dir, "rev-parse", "--verify", id.String()+"^{tree}")...)
209	if err != nil {
210		return domain.ObjectID{}, fmt.Errorf("tree of %s in %q: %w", id, repo, err)
211	}
212	return parseID(out)
213}
214
215// WriteCommit runs `git hash-object -t commit -w --stdin`, which also checks
216// that the object is a well-formed commit.
217func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error) {
218	dir, err := g.repoDir(repo)
219	if err != nil {
220		return domain.ObjectID{}, err
221	}
222	out, err := g.runInput(ctx, bytes.NewReader(object), "--git-dir="+dir, "hash-object", "-t", "commit", "-w", "--stdin")
223	if err != nil {
224		return domain.ObjectID{}, fmt.Errorf("write commit in %q: %w", repo, err)
225	}
226	return parseID(out)
227}
228
229// UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which
230// takes the ref lock and refuses when the ref is not at old. A failure is
231// told from a moved branch by reading the branch again.
232//
233//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
234func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {
235	dir, err := g.repoDir(repo)
236	if err != nil {
237		return err
238	}
239	if _, err := domain.ParseBranchName(branch); err != nil {
240		return fmt.Errorf("update branch of %q: %w", repo, err)
241	}
242	_, err = g.run(ctx, "--git-dir="+dir, "update-ref", headsRefPrefix+branch, newID.String(), old.String())
243	if err == nil {
244		return nil
245	}
246	if cur, herr := g.BranchHead(ctx, repo, branch); herr == nil && cur != old {
247		return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
248	}
249	return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)
250}
251
252// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
253// the ref lock and refuses when the ref is not at old. A failure is told from
254// a moved or deleted branch by reading the branch again.
255//
256//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
257func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
258	dir, err := g.repoDir(repo)
259	if err != nil {
260		return err
261	}
262	if _, err := domain.ParseBranchName(branch); err != nil {
263		return fmt.Errorf("delete branch of %q: %w", repo, err)
264	}
265	_, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
266	if err == nil {
267		return nil
268	}
269	switch cur, herr := g.BranchHead(ctx, repo, branch); {
270	case errors.Is(herr, domain.ErrNotFound):
271		return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
272	case herr == nil && cur != old:
273		return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
274	}
275	return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
276}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close