detached 7ce5542

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
5555 if len(commit.Parents) > 0 {
5656 base = commit.Parents[0].String()
5757 }
58-
5958 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+ )
6072 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 {
6375 var err error
64- detail.Diff, detail.Stats, err = readDiff(r, limits)
76+ diff, stats, err = readDiff(r, limits)
6577 return err
6678 })
6779 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
6981 }
70- return detail, nil
82+ return stats, diff, nil
7183 }
7284
7385 // 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 error75 var err error
64- detail.Diff, detail.Stats, err = readDiff(r, limits)76+ diff, stats, err = readDiff(r, limits)
65 return err77 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, nil82+ 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 (
1010 "context"
1111 "errors"
1212 "fmt"
13+ "io"
1314 "os/exec"
1415 "path/filepath"
1516 "runtime"
@@ -122,6 +123,11 @@ func (g *Git) acquireExec(ctx context.Context) (release func(), err error) {
122123
123124 // run executes git with args in the isolated environment and returns stdout.
124125 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) {
125131 if _, ok := ctx.Deadline(); !ok {
126132 var cancel context.CancelFunc
127133 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -136,6 +142,7 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) {
136142 cmd.Env = g.env
137143 cmd.Dir = g.dataDir
138144 cmd.WaitDelay = 5 * time.Second
145+ cmd.Stdin = stdin
139146 var stdout, stderr bytes.Buffer
140147 cmd.Stdout = &stdout
141148 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.CancelFunc132 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.env142 cmd.Env = g.env
137 cmd.Dir = g.dataDir143 cmd.Dir = g.dataDir
138 cmd.WaitDelay = 5 * time.Second144 cmd.WaitDelay = 5 * time.Second
145+ cmd.Stdin = stdin
139 var stdout, stderr bytes.Buffer146 var stdout, stderr bytes.Buffer
140 cmd.Stdout = &stdout147 cmd.Stdout = &stdout
141 cmd.Stderr = &stderr148 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+}

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