detached 83e495f

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`.
42func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
43	dir, err := g.repoDir(repo)
44	if err != nil {
45		return domain.ObjectID{}, err
46	}
47	if _, err := domain.ParseBranchName(branch); err != nil {
48		return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound)
49	}
50	var id domain.ObjectID
51	err = g.cat.do(ctx, dir, func(c *catConn) error {
52		h, err := c.info(headsRefPrefix + branch + "^{commit}")
53		if err != nil {
54			return err
55		}
56		id, err = domain.ParseObjectID(h.ID)
57		if err != nil {
58			return c.fail(err)
59		}
60		return nil
61	})
62	if notFound(err) {
63		return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound)
64	}
65	if err != nil {
66		return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, err)
67	}
68	return id, nil
69}
70
71// HasCommit asks `cat-file` for the type of the object.
72func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error) {
73	dir, err := g.repoDir(repo)
74	if err != nil {
75		return false, err
76	}
77	found := false
78	err = g.cat.do(ctx, dir, func(c *catConn) error {
79		h, err := c.info(id.String())
80		if notFound(err) {
81			return nil
82		}
83		if err != nil {
84			return err
85		}
86		found = h.Type == "commit"
87		return nil
88	})
89	if err != nil {
90		return false, fmt.Errorf("look up commit %s in %q: %w", id, repo, err)
91	}
92	return found, nil
93}
94
95// MergeBase runs `git merge-base`, which exits 1 when there is none.
96func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) {
97	dir, err := g.repoDir(repo)
98	if err != nil {
99		return domain.ObjectID{}, err
100	}
101	out, err := g.run(ctx, readArgs(dir, "merge-base", a.String(), b.String())...)
102	switch {
103	case exitCode(err) == 1:
104		return domain.ObjectID{}, fmt.Errorf("%s and %s share no history: %w", a, b, domain.ErrNotFound)
105	case err != nil:
106		return domain.ObjectID{}, fmt.Errorf("merge base in %q: %w", repo, err)
107	}
108	return parseID(out)
109}
110
111// IsAncestor runs `git merge-base --is-ancestor`, which exits 1 for "no".
112func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error) {
113	dir, err := g.repoDir(repo)
114	if err != nil {
115		return false, err
116	}
117	_, err = g.run(ctx, readArgs(dir, "merge-base", "--is-ancestor", ancestor.String(), descendant.String())...)
118	switch {
119	case err == nil:
120		return true, nil
121	case exitCode(err) == 1:
122		return false, nil
123	}
124	return false, fmt.Errorf("ancestry in %q: %w", repo, err)
125}
126
127// ListRange runs `git log <head> ^<exclude>`.
128func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
129	var excluded []string
130	if !exclude.IsZero() {
131		excluded = []string{"^" + exclude.String()}
132	}
133	return g.logCommits(ctx, repo, head, limit, nil, excluded)
134}
135
136// logCommits runs `git log` for the commits reachable from head: revArgs go
137// before the head (options such as --not), excluded after it. Newest first,
138// at most limit; more says there were others.
139func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
140	if limit < 1 || limit > ports.MaxCommitLimit {
141		return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
142	}
143	dir, err := g.repoDir(repo)
144	if err != nil {
145		return nil, false, err
146	}
147	args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
148	args = append(args, revArgs...)
149	args = append(args, "--end-of-options", head.String())
150	args = append(args, excluded...)
151	var (
152		commits []domain.Commit
153		more    bool
154	)
155	err = g.pipe(ctx, args, func(r *bufio.Reader) error {
156		for {
157			c, err := readCommit(r)
158			if errors.Is(err, io.EOF) {
159				return nil
160			}
161			if err != nil {
162				return err
163			}
164			if len(commits) == limit {
165				more = true
166				return errStop
167			}
168			commits = append(commits, c)
169		}
170	})
171	if err != nil {
172		return nil, false, fmt.Errorf("commits of %s in %q: %w", head, repo, err)
173	}
174	return commits, more, nil
175}
176
177// DiffCommits reads the diff between two commits like GetCommit does.
178func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
179	dir, err := g.repoDir(repo)
180	if err != nil {
181		return domain.DiffStats{}, domain.Diff{}, err
182	}
183	stats, diff, err := g.diffTrees(ctx, dir, base.String(), head.String(), limits)
184	if err != nil {
185		return domain.DiffStats{}, domain.Diff{}, fmt.Errorf("diff %s..%s in %q: %w", base, head, repo, err)
186	}
187	return stats, diff, nil
188}
189
190// CommitTree runs `git rev-parse <commit>^{tree}`.
191func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {
192	dir, err := g.repoDir(repo)
193	if err != nil {
194		return domain.ObjectID{}, err
195	}
196	out, err := g.run(ctx, readArgs(dir, "rev-parse", "--verify", id.String()+"^{tree}")...)
197	if err != nil {
198		return domain.ObjectID{}, fmt.Errorf("tree of %s in %q: %w", id, repo, err)
199	}
200	return parseID(out)
201}
202
203// WriteCommit runs `git hash-object -t commit -w --stdin`, which also checks
204// that the object is a well-formed commit.
205func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error) {
206	dir, err := g.repoDir(repo)
207	if err != nil {
208		return domain.ObjectID{}, err
209	}
210	out, err := g.runInput(ctx, bytes.NewReader(object), "--git-dir="+dir, "hash-object", "-t", "commit", "-w", "--stdin")
211	if err != nil {
212		return domain.ObjectID{}, fmt.Errorf("write commit in %q: %w", repo, err)
213	}
214	return parseID(out)
215}
216
217// UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which
218// takes the ref lock and refuses when the ref is not at old. A failure is
219// told from a moved branch by reading the branch again.
220func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {
221	dir, err := g.repoDir(repo)
222	if err != nil {
223		return err
224	}
225	if _, err := domain.ParseBranchName(branch); err != nil {
226		return fmt.Errorf("update branch of %q: %w", repo, err)
227	}
228	_, err = g.run(ctx, "--git-dir="+dir, "update-ref", headsRefPrefix+branch, newID.String(), old.String())
229	if err == nil {
230		return nil
231	}
232	if cur, herr := g.BranchHead(ctx, repo, branch); herr == nil && cur != old {
233		return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
234	}
235	return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)
236}
237
238// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
239// the ref lock and refuses when the ref is not at old. A failure is told from
240// a moved or deleted branch by reading the branch again.
241func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
242	dir, err := g.repoDir(repo)
243	if err != nil {
244		return err
245	}
246	if _, err := domain.ParseBranchName(branch); err != nil {
247		return fmt.Errorf("delete branch of %q: %w", repo, err)
248	}
249	_, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
250	if err == nil {
251		return nil
252	}
253	switch cur, herr := g.BranchHead(ctx, repo, branch); {
254	case errors.Is(herr, domain.ErrNotFound):
255		return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
256	case herr == nil && cur != old:
257		return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
258	}
259	return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
260}

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 4

Tags 45

↑↓ moveenter openesc close