Skip to content

Commit cc55047

Browse files
XeTest
andauthored
feat(ssh): accept tags and commits for sh (#21)
Let the SSH shell select a branch, tag, or commit hash. Peel annotated tags to the target commit so the sandbox can load its tree. Assisted-by: GPT-6 via Codex Signed-off-by: Test <test@example.com> Co-authored-by: Test <test@example.com>
1 parent 3a69dfc commit cc55047

4 files changed

Lines changed: 81 additions & 26 deletions

File tree

‎cmd/objgitd/shell.go‎

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,20 @@ import (
2424
"mvdan.cc/sh/v3/syntax"
2525
)
2626

27-
const shellUsage = "usage: sh <repo> [branch]"
27+
const shellUsage = "usage: sh <repo> [branch, tag, or commit]"
2828

2929
// errShellInterrupt is what shellLines returns when Ctrl-C is pressed at the
3030
// prompt, so the parser drops the pending input.
3131
var errShellInterrupt = errors.New("interrupt")
3232

33-
// shellTarget resolves the commit an sh session inspects: the tip of branch, or
34-
// of HEAD's branch when branch is empty. The returned update describes that
35-
// commit as if it had just been pushed: Old is its first parent, or the zero
36-
// hash for a root commit.
37-
func shellTarget(st storage.Storer, branch string) (refUpdate, *object.Tree, error) {
38-
name := plumbing.NewBranchReferenceName(branch)
39-
if branch == "" {
33+
// shellTarget resolves the commit an sh session inspects: a branch, tag, or
34+
// commit hash, or the tip of HEAD's branch when revision is empty. The
35+
// returned update describes that commit as if it had just been pushed: Old is
36+
// its first parent, or the zero hash for a root commit.
37+
func shellTarget(st storage.Storer, revision string) (refUpdate, *object.Tree, error) {
38+
var name plumbing.ReferenceName
39+
var hash plumbing.Hash
40+
if revision == "" {
4041
head, err := st.Reference(plumbing.HEAD)
4142
if err != nil {
4243
return refUpdate{}, nil, fmt.Errorf("reading HEAD: %w", err)
@@ -45,19 +46,45 @@ func shellTarget(st storage.Storer, branch string) (refUpdate, *object.Tree, err
4546
return refUpdate{}, nil, errors.New("HEAD is detached; name a branch: " + shellUsage)
4647
}
4748
name = head.Target()
49+
} else if strings.HasPrefix(revision, "refs/") {
50+
name = plumbing.ReferenceName(revision)
51+
} else {
52+
for _, candidate := range []plumbing.ReferenceName{
53+
plumbing.NewBranchReferenceName(revision),
54+
plumbing.NewTagReferenceName(revision),
55+
} {
56+
ref, err := st.Reference(candidate)
57+
if err == nil {
58+
name, hash = ref.Name(), ref.Hash()
59+
break
60+
}
61+
if !errors.Is(err, plumbing.ErrReferenceNotFound) {
62+
return refUpdate{}, nil, fmt.Errorf("reading %s: %w", candidate, err)
63+
}
64+
}
65+
if hash.IsZero() {
66+
if !plumbing.IsHash(revision) {
67+
return refUpdate{}, nil, fmt.Errorf("branch %q not found", revision)
68+
}
69+
hash = plumbing.NewHash(revision)
70+
name = plumbing.ReferenceName("refs/commits/" + revision)
71+
}
4872
}
4973

50-
ref, err := st.Reference(name)
51-
if errors.Is(err, plumbing.ErrReferenceNotFound) {
52-
return refUpdate{}, nil, fmt.Errorf("branch %q not found", name.Short())
53-
}
54-
if err != nil {
55-
return refUpdate{}, nil, fmt.Errorf("reading %s: %w", name, err)
74+
if hash.IsZero() {
75+
ref, err := st.Reference(name)
76+
if errors.Is(err, plumbing.ErrReferenceNotFound) {
77+
return refUpdate{}, nil, fmt.Errorf("revision %q not found", revision)
78+
}
79+
if err != nil {
80+
return refUpdate{}, nil, fmt.Errorf("reading %s: %w", name, err)
81+
}
82+
hash = ref.Hash()
5683
}
5784

58-
commit, err := object.GetCommit(st, ref.Hash())
85+
commit, err := shellCommit(st, hash)
5986
if err != nil {
60-
return refUpdate{}, nil, fmt.Errorf("loading commit %s: %w", ref.Hash(), err)
87+
return refUpdate{}, nil, fmt.Errorf("loading commit %s: %w", hash, err)
6188
}
6289
tree, err := commit.Tree()
6390
if err != nil {
@@ -71,9 +98,34 @@ func shellTarget(st storage.Storer, branch string) (refUpdate, *object.Tree, err
7198
return u, tree, nil
7299
}
73100

74-
// handleShell services "sh <repo> [branch]": an interactive kefka shell with
101+
// shellCommit peels annotated tags until it reaches a commit. Git tags can
102+
// point to other tags or to non-commit objects, neither of which is a usable
103+
// shell checkout.
104+
func shellCommit(st storage.Storer, hash plumbing.Hash) (*object.Commit, error) {
105+
for range 16 {
106+
obj, err := st.EncodedObject(plumbing.AnyObject, hash)
107+
if err != nil {
108+
return nil, err
109+
}
110+
switch obj.Type() {
111+
case plumbing.CommitObject:
112+
return object.DecodeCommit(st, obj)
113+
case plumbing.TagObject:
114+
tag, err := object.DecodeTag(st, obj)
115+
if err != nil {
116+
return nil, err
117+
}
118+
hash = tag.Target
119+
default:
120+
return nil, fmt.Errorf("object %s is not a commit or tag", hash)
121+
}
122+
}
123+
return nil, errors.New("tag chain is too deep")
124+
}
125+
126+
// handleShell services "sh <repo> [revision]": an interactive kefka shell with
75127
// the sandbox and environment a receive-pack hook gets, filled in from the
76-
// branch's tip commit. It needs -allow-hooks, a PTY, and write access to the
128+
// selected revision's commit. It needs -allow-hooks, a PTY, and write access to the
77129
// repository, because only a pusher can trigger a hook.
78130
func (d *daemon) handleShell(s ssh.Session, args []string) {
79131
fail := func(format string, a ...any) {
@@ -100,9 +152,9 @@ func (d *daemon) handleShell(s ssh.Session, args []string) {
100152
fail("%v", err)
101153
return
102154
}
103-
var branch string
155+
var revision string
104156
if len(args) == 2 {
105-
branch = args[1]
157+
revision = args[1]
106158
}
107159

108160
var cred auth.Credential = auth.Anonymous{}
@@ -132,7 +184,7 @@ func (d *daemon) handleShell(s ssh.Session, args []string) {
132184
fail("repository %q not found", ref.Path())
133185
return
134186
}
135-
u, tree, err := shellTarget(st, branch)
187+
u, tree, err := shellTarget(st, revision)
136188
if err != nil {
137189
metrics.ObserveGitOp("ssh", "sh", "error", start)
138190
fail("%v", err)

‎cmd/objgitd/shell_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ func TestSSHShell(t *testing.T) {
357357
allowHooks: true,
358358
forcePTY: true,
359359
wantCode: 1,
360-
want: []string{"usage: sh <repo> [branch]"},
360+
want: []string{"usage: sh <repo> [branch, tag, or commit]"},
361361
},
362362
} {
363363
t.Run(tt.name, func(t *testing.T) {

‎cmd/objgitd/ssh.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func (d *daemon) handleSSH(s ssh.Session) {
135135
if len(cmd) < 2 {
136136
fmt.Fprintln(s.Stderr(), "objgitd: this is a git SSH endpoint; interactive shells are not supported")
137137
if d.allowHooks {
138-
fmt.Fprintln(s.Stderr(), "objgitd: to explore the hook sandbox, run: ssh -t <host> sh <repo> [branch]")
138+
fmt.Fprintln(s.Stderr(), "objgitd: to explore the hook sandbox, run: ssh -t <host> sh <repo> [revision]")
139139
}
140140
_ = s.Exit(1)
141141
return

‎docs/architecture/transports.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,13 @@ restart, and the server needs no local disk.
5959
Receive-pack goes through `d.receivePack`, and not through
6060
`transport.ReceivePack`, so [push hooks](hooks.md) also run over SSH.
6161

62-
**One command is not git.** `sh <repo> [branch]` goes to `handleShell` in
62+
**One command is not git.** `sh <repo> [revision]` goes to `handleShell` in
6363
`shell.go`, before `gitServiceFor` runs. It opens the [hook sandbox](hooks.md)
64-
as an interactive shell over the session PTY. It needs `-allow-hooks`, a PTY,
65-
and `auth.Write` on the repository, because only a pusher can trigger a hook.
64+
as an interactive shell over the session PTY. The optional revision can be a
65+
branch name, tag name, full ref name, or full commit hash. Annotated tags are
66+
peeled to their target commit. With no revision, it uses the branch named by
67+
HEAD. It needs `-allow-hooks`, a PTY, and `auth.Write` on the repository,
68+
because only a pusher can trigger a hook.
6669

6770
Protocol v2 is not forwarded yet. `s.Environ()` carries `GIT_PROTOCOL`, but v0
6871
and v1 are enough for now.

0 commit comments

Comments
 (0)