v0.5.0 bfab294

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	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
175func (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
190func (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.
204func (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
221func (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}
238
239// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
240// the ref lock and refuses when the ref is not at old. A failure is told from
241// a moved or deleted branch by reading the branch again.
242//
243//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
244func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
245	dir, err := g.repoDir(repo)
246	if err != nil {
247		return err
248	}
249	if _, err := domain.ParseBranchName(branch); err != nil {
250		return fmt.Errorf("delete branch of %q: %w", repo, err)
251	}
252	_, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
253	if err == nil {
254		return nil
255	}
256	switch cur, herr := g.BranchHead(ctx, repo, branch); {
257	case errors.Is(herr, domain.ErrNotFound):
258		return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
259	case herr == nil && cur != old:
260		return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
261	}
262	return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
263}

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