7f3e30430a085e0e19ca4724f8e0c9185b3b289a
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.gitignore b/.gitignore
2index 7f48767177e75ab62894454f61761e963f03f43e..ae70eac83c310247658c36ab11f4e764026d29b1 100644
3--- a/.gitignore
4+++ b/.gitignore
5@@ -1,7 +1,10 @@
6 cmd/soft/soft
7+./soft
8 .ssh
9 .repos
10 dist
11 testdata
12+data/
13 completions/
14-manpages/
15+manpages/
16+soft_serve_ed25519*
17diff --git a/cmd/soft/man.go b/cmd/soft/man.go
18index ae59fec1bafe3f201c1fe4ec3a0a4a2f3ee298a2..71b1dfb2c3534a3e41d57baf34b5c0fadd09dd59 100644
19--- a/cmd/soft/man.go
20+++ b/cmd/soft/man.go
21@@ -20,7 +20,7 @@ var (
22 return err
23 }
24
25- manPage = manPage.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+
26+ manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+
27 "Released under MIT license.")
28 fmt.Println(manPage.Build(roff.NewDocument()))
29 return nil
30diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go
31index 5b0b49787f52dee887e2ec794460c78e503d874b..469ba8546db82fdff95896d7a7c36e593c1b7ead 100644
32--- a/cmd/soft/serve.go
33+++ b/cmd/soft/serve.go
34@@ -2,14 +2,12 @@ package main
35
36 import (
37 "context"
38- "fmt"
39 "os"
40 "os/signal"
41 "syscall"
42 "time"
43
44 "github.com/charmbracelet/log"
45-
46 "github.com/charmbracelet/soft-serve/server"
47 "github.com/charmbracelet/soft-serve/server/config"
48 "github.com/spf13/cobra"
49@@ -23,9 +21,14 @@ var (
50 Args: cobra.NoArgs,
51 RunE: func(cmd *cobra.Command, args []string) error {
52 cfg := config.DefaultConfig()
53- s := server.NewServer(cfg)
54+ s, err := server.NewServer(cfg)
55+ if err != nil {
56+ return err
57+ }
58
59- log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
60+ if cfg.Debug {
61+ log.SetLevel(log.DebugLevel)
62+ }
63
64 done := make(chan os.Signal, 1)
65 lch := make(chan error, 1)
66@@ -38,7 +41,6 @@ var (
67 signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
68 <-done
69
70- log.Print("Stopping SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
71 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
72 defer cancel()
73 if err := s.Shutdown(ctx); err != nil {
74diff --git a/config/auth.go b/config/auth.go
75deleted file mode 100644
76index 84da840f476778ff1c9aaeb9441d308db078d32f..0000000000000000000000000000000000000000
77--- a/config/auth.go
78+++ /dev/null
79@@ -1,155 +0,0 @@
80-package config
81-
82-import (
83- "strings"
84-
85- "github.com/charmbracelet/log"
86-
87- gm "github.com/charmbracelet/wish/git"
88- "github.com/gliderlabs/ssh"
89- gossh "golang.org/x/crypto/ssh"
90-)
91-
92-// Push registers Git push functionality for the given repo and key.
93-func (cfg *Config) Push(repo string, pk ssh.PublicKey) {
94- go func() {
95- err := cfg.Reload()
96- if err != nil {
97- log.Error("error reloading after push", "err", err)
98- }
99- if cfg.Cfg.Callbacks != nil {
100- cfg.Cfg.Callbacks.Push(repo)
101- }
102- r, err := cfg.Source.GetRepo(repo)
103- if err != nil {
104- log.Error("error getting repo after push", "err", err)
105- return
106- }
107- err = r.UpdateServerInfo()
108- if err != nil {
109- log.Error("error updating server info after push", "err", err)
110- }
111- }()
112-}
113-
114-// Fetch registers Git fetch functionality for the given repo and key.
115-func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) {
116- if cfg.Cfg.Callbacks != nil {
117- cfg.Cfg.Callbacks.Fetch(repo)
118- }
119-}
120-
121-// AuthRepo grants repo authorization to the given key.
122-func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel {
123- return cfg.accessForKey(repo, pk)
124-}
125-
126-// PasswordHandler returns whether or not password access is allowed.
127-func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool {
128- return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
129-}
130-
131-// KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed.
132-func (cfg *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
133- return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
134-}
135-
136-// PublicKeyHandler returns whether or not the given public key may access the
137-// repo.
138-func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
139- return cfg.accessForKey("", pk) != gm.NoAccess
140-}
141-
142-func (cfg *Config) anonAccessLevel() gm.AccessLevel {
143- switch cfg.AnonAccess {
144- case "no-access":
145- return gm.NoAccess
146- case "read-only":
147- return gm.ReadOnlyAccess
148- case "read-write":
149- return gm.ReadWriteAccess
150- case "admin-access":
151- return gm.AdminAccess
152- default:
153- return gm.NoAccess
154- }
155-}
156-
157-// accessForKey returns the access level for the given repo.
158-//
159-// If repo doesn't exist, then access is based on user's admin privileges, or
160-// config.AnonAccess.
161-// If repo exists, and private, then admins and collabs are allowed access.
162-// If repo exists, and not private, then access is based on config.AnonAccess.
163-func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel {
164- anon := cfg.anonAccessLevel()
165- private := cfg.isPrivate(repo)
166- // Find user
167- for _, user := range cfg.Users {
168- for _, k := range user.PublicKeys {
169- apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k)))
170- if err != nil {
171- log.Error("malformed authorized key", "key", k)
172- return gm.NoAccess
173- }
174- if ssh.KeysEqual(pk, apk) {
175- if user.Admin {
176- return gm.AdminAccess
177- }
178- u := user
179diff --git a/config/auth_test.go b/config/auth_test.go
180deleted file mode 100644
181index 4f48bafb47cae15a0ba47b17a8a5f6db04c2bce8..0000000000000000000000000000000000000000
182--- a/config/auth_test.go
183+++ /dev/null
184@@ -1,669 +0,0 @@
185-package config
186-
187-import (
188- "testing"
189-
190- "github.com/charmbracelet/wish/git"
191- "github.com/gliderlabs/ssh"
192- "github.com/matryer/is"
193-)
194-
195-func TestAuth(t *testing.T) {
196- adminKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b"
197- adminPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(adminKey))
198- dummyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"
199- dummyPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(dummyKey))
200- cases := []struct {
201- name string
202- cfg Config
203- repo string
204- key ssh.PublicKey
205- access git.AccessLevel
206- }{
207- // Repo access
208- {
209- name: "anon access: no-access, anonymous user",
210- access: git.NoAccess,
211- repo: "foo",
212- cfg: Config{
213- AnonAccess: "no-access",
214- Repos: []RepoConfig{
215- {
216- Repo: "foo",
217- },
218- },
219- },
220- },
221- {
222- name: "anon access: no-access, anonymous user with admin user",
223- access: git.NoAccess,
224- repo: "foo",
225- cfg: Config{
226- AnonAccess: "no-access",
227- Repos: []RepoConfig{
228- {
229- Repo: "foo",
230- },
231- },
232- Users: []User{
233- {
234- Admin: true,
235- PublicKeys: []string{
236- adminKey,
237- },
238- },
239- },
240- },
241- },
242- {
243- name: "anon access: no-access, authd user",
244- key: dummyPk,
245- repo: "foo",
246- access: git.ReadOnlyAccess,
247- cfg: Config{
248- AnonAccess: "no-access",
249- Repos: []RepoConfig{
250- {
251- Repo: "foo",
252- },
253- },
254- Users: []User{
255- {
256- PublicKeys: []string{
257- dummyKey,
258- },
259- },
260- },
261- },
262- },
263- {
264- name: "anon access: no-access, anonymous user with admin user",
265- key: dummyPk,
266- repo: "foo",
267- access: git.NoAccess,
268- cfg: Config{
269- AnonAccess: "no-access",
270- Repos: []RepoConfig{
271- {
272- Repo: "foo",
273- },
274- },
275- Users: []User{
276- {
277- Admin: true,
278- PublicKeys: []string{
279- adminKey,
280- },
281- },
282- },
283- },
284diff --git a/config/config.go b/config/config.go
285deleted file mode 100644
286index 27e083d6caefa0a042654f1ab809af218e86498a..0000000000000000000000000000000000000000
287--- a/config/config.go
288+++ /dev/null
289@@ -1,334 +0,0 @@
290-package config
291-
292-import (
293- "bytes"
294- "encoding/json"
295- "errors"
296- "io/fs"
297- "path/filepath"
298- "strings"
299- "sync"
300- "text/template"
301- "time"
302-
303- "github.com/charmbracelet/log"
304-
305- "golang.org/x/crypto/ssh"
306- "gopkg.in/yaml.v3"
307-
308- "fmt"
309- "os"
310-
311- "github.com/charmbracelet/soft-serve/git"
312- "github.com/charmbracelet/soft-serve/server/config"
313- "github.com/go-git/go-billy/v5/memfs"
314- ggit "github.com/go-git/go-git/v5"
315- "github.com/go-git/go-git/v5/plumbing/object"
316- "github.com/go-git/go-git/v5/plumbing/transport"
317- "github.com/go-git/go-git/v5/storage/memory"
318-)
319-
320-var (
321- // ErrNoConfig is returned when a repo has no config file.
322- ErrNoConfig = errors.New("no config file found")
323-)
324-
325-// Config is the Soft Serve configuration.
326-type Config struct {
327- Name string `yaml:"name" json:"name"`
328- Host string `yaml:"host" json:"host"`
329- Port int `yaml:"port" json:"port"`
330- AnonAccess string `yaml:"anon-access" json:"anon-access"`
331- AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"`
332- Users []User `yaml:"users" json:"users"`
333- Repos []RepoConfig `yaml:"repos" json:"repos"`
334- Source *RepoSource `yaml:"-" json:"-"`
335- Cfg *config.Config `yaml:"-" json:"-"`
336- mtx sync.Mutex
337-}
338-
339-// User contains user-level configuration for a repository.
340-type User struct {
341- Name string `yaml:"name" json:"name"`
342- Admin bool `yaml:"admin" json:"admin"`
343- PublicKeys []string `yaml:"public-keys" json:"public-keys"`
344- CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
345-}
346-
347-// RepoConfig is a repository configuration.
348-type RepoConfig struct {
349- Name string `yaml:"name" json:"name"`
350- Repo string `yaml:"repo" json:"repo"`
351- Note string `yaml:"note" json:"note"`
352- Private bool `yaml:"private" json:"private"`
353- Readme string `yaml:"readme" json:"readme"`
354- Collabs []string `yaml:"collabs" json:"collabs"`
355-}
356-
357-// NewConfig creates a new internal Config struct.
358-func NewConfig(cfg *config.Config) (*Config, error) {
359- var anonAccess string
360- var yamlUsers string
361- var displayHost string
362- host := cfg.Host
363- port := cfg.Port
364-
365- pks := make([]string, 0)
366- for _, k := range cfg.InitialAdminKeys {
367- if bts, err := os.ReadFile(k); err == nil {
368- // pk is a file, set its contents as pk
369- k = string(bts)
370- }
371- var pk = strings.TrimSpace(k)
372- if pk == "" {
373- continue
374- }
375- // it is a valid ssh key, nothing to do
376- if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
377- return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
378- }
379- pks = append(pks, pk)
380- }
381-
382- rs := NewRepoSource(cfg.RepoPath)
383- c := &Config{
384- Cfg: cfg,
385- }
386- c.Host = cfg.Host
387- c.Port = port
388- c.Source = rs
389diff --git a/config/config_test.go b/config/config_test.go
390deleted file mode 100644
391index 12ddd8c244a7a556f1498dace6b7da61a1e5d706..0000000000000000000000000000000000000000
392--- a/config/config_test.go
393+++ /dev/null
394@@ -1,37 +0,0 @@
395-package config
396-
397-import (
398- "testing"
399-
400- "github.com/charmbracelet/soft-serve/server/config"
401- "github.com/matryer/is"
402-)
403-
404-func TestMultipleInitialKeys(t *testing.T) {
405- cfg, err := NewConfig(&config.Config{
406- RepoPath: t.TempDir(),
407- KeyPath: t.TempDir(),
408- InitialAdminKeys: []string{
409- "testdata/k1.pub",
410- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
411- },
412- })
413- is := is.New(t)
414- is.NoErr(err)
415- err = cfg.Reload()
416- is.NoErr(err)
417- is.Equal(cfg.Users[0].PublicKeys, []string{
418- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
419- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
420- }) // should have both keys
421-}
422-
423-func TestEmptyInitialKeys(t *testing.T) {
424- cfg, err := NewConfig(&config.Config{
425- RepoPath: t.TempDir(),
426- KeyPath: t.TempDir(),
427- })
428- is := is.New(t)
429- is.NoErr(err)
430- is.Equal(len(cfg.Users), 0) // should not have any users
431-}
432diff --git a/config/defaults.go b/config/defaults.go
433deleted file mode 100644
434index a471fab58bdf9a21cf977b8de1f8a6f94e9c215a..0000000000000000000000000000000000000000
435--- a/config/defaults.go
436+++ /dev/null
437@@ -1,58 +0,0 @@
438-package config
439-
440-const defaultReadme = "# Soft Serve\n\n Welcome! You can configure your Soft Serve server by cloning this repo and pushing changes.\n\n```\ngit clone ssh://{{.Host}}:{{.Port}}/config\n```"
441-
442-const defaultConfig = `# The name of the server to show in the TUI.
443-name: Soft Serve
444-
445-# The host and port to display in the TUI. You may want to change this if your
446-# server is accessible from a different host and/or port that what it's
447-# actually listening on (for example, if it's behind a reverse proxy).
448-host: %s
449-port: %d
450-
451-# Access level for anonymous users. Options are: admin-access, read-write,
452-# read-only, and no-access.
453-anon-access: %s
454-
455-# You can grant read-only access to users without private keys. Any password
456-# will be accepted.
457-allow-keyless: %t
458-
459-# Customize repo display in the menu.
460-repos:
461- - name: Home
462- repo: config
463- private: true
464- note: "Configuration and content repo for this server"
465- readme: README.md
466-`
467-
468-const hasKeyUserConfig = `
469-
470-# Authorized users. Admins have full access to all repos. Private repos are only
471-# accessible by admins and collab users. Regular users can read public repos
472-# based on your anon-access setting.
473-users:
474- - name: Admin
475- admin: true
476- public-keys:
477-%s
478-`
479-
480-const defaultUserConfig = `
481-# users:
482-# - name: Admin
483-# admin: true
484-# public-keys:
485-# - ssh-ed25519 AAAA... # redacted
486-# - ssh-rsa AAAAB3Nz... # redacted`
487-
488-const exampleUserConfig = `
489-# - name: Example User
490-# collab-repos:
491-# - REPO
492-# public-keys:
493-# - ssh-ed25519 AAAA... # redacted
494-# - ssh-rsa AAAAB3Nz... # redacted
495-`
496diff --git a/config/git.go b/config/git.go
497deleted file mode 100644
498index 9835a4f1d9d8a06ec9f00bc603c7d8685ca2dd94..0000000000000000000000000000000000000000
499--- a/config/git.go
500+++ /dev/null
501@@ -1,304 +0,0 @@
502-package config
503-
504-import (
505- "errors"
506- "os"
507- "path/filepath"
508- "sync"
509-
510- "github.com/charmbracelet/log"
511-
512- "github.com/charmbracelet/soft-serve/git"
513- "github.com/gobwas/glob"
514- "github.com/golang/groupcache/lru"
515-)
516-
517-// ErrMissingRepo indicates that the requested repository could not be found.
518-var ErrMissingRepo = errors.New("missing repo")
519-
520-// Repo represents a Git repository.
521-type Repo struct {
522- name string
523- description string
524- path string
525- repository *git.Repository
526- readme string
527- readmePath string
528- head *git.Reference
529- headCommit string
530- refs []*git.Reference
531- patchCache *lru.Cache
532- private bool
533-}
534-
535-// open opens a Git repository.
536-func (rs *RepoSource) open(path string) (*Repo, error) {
537- rg, err := git.Open(path)
538- if err != nil {
539- return nil, err
540- }
541- r := &Repo{
542- path: path,
543- repository: rg,
544- patchCache: lru.New(1000),
545- }
546- _, err = r.HEAD()
547- if err != nil {
548- return nil, err
549- }
550- _, err = r.References()
551- if err != nil {
552- return nil, err
553- }
554- return r, nil
555-}
556-
557-// IsPrivate returns true if the repository is private.
558-func (r *Repo) IsPrivate() bool {
559- return r.private
560-}
561-
562-// Path returns the path to the repository.
563-func (r *Repo) Path() string {
564- return r.path
565-}
566-
567-// Repo returns the repository directory name.
568-func (r *Repo) Repo() string {
569- return filepath.Base(r.path)
570-}
571-
572-// Name returns the name of the repository.
573-func (r *Repo) Name() string {
574- if r.name == "" {
575- return r.Repo()
576- }
577- return r.name
578-}
579-
580-// Description returns the description for a repository.
581-func (r *Repo) Description() string {
582- return r.description
583-}
584-
585-// Readme returns the readme and its path for the repository.
586-func (r *Repo) Readme() (readme string, path string) {
587- return r.readme, r.readmePath
588-}
589-
590-// SetReadme sets the readme for the repository.
591-func (r *Repo) SetReadme(readme, path string) {
592- r.readme = readme
593- r.readmePath = path
594-}
595-
596-// HEAD returns the reference for a repository.
597-func (r *Repo) HEAD() (*git.Reference, error) {
598- if r.head != nil {
599- return r.head, nil
600- }
601diff --git a/config/testdata/k1.pub b/config/testdata/k1.pub
602deleted file mode 100644
603index d82e29394d343e6e36bc1759b06689a399ea80a4..0000000000000000000000000000000000000000
604--- a/config/testdata/k1.pub
605+++ /dev/null
606@@ -1 +0,0 @@
607-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b
608diff --git a/examples/setuid/main.go b/examples/setuid/main.go
609index 4a722eb9b2aef002a5ce6867252ce08a2a65186d..8999dbcc25fe9f2c379c5d46ddd2f998da524067 100644
610--- a/examples/setuid/main.go
611+++ b/examples/setuid/main.go
612@@ -45,22 +45,25 @@ func main() {
613 log.Fatal("Setuid error", "err", err)
614 }
615 cfg := config.DefaultConfig()
616- cfg.Port = *port
617- s := server.NewServer(cfg)
618+ cfg.SSH.ListenAddr = fmt.Sprintf(":%d", *port)
619+ s, err := server.NewServer(cfg)
620+ if err != nil {
621+ log.Fatal(err)
622+ }
623
624 done := make(chan os.Signal, 1)
625 signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
626
627- log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
628+ log.Print("Starting SSH server", "addr", cfg.SSH.ListenAddr)
629 go func() {
630- if err := s.Serve(ls); err != nil {
631+ if err := s.SSHServer.Serve(ls); err != nil {
632 log.Fatal(err)
633 }
634 }()
635
636 <-done
637
638- log.Print("Stopping SSH server", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
639+ log.Print("Stopping SSH server", "addr", cfg.SSH.ListenAddr)
640 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
641 defer func() { cancel() }()
642 if err := s.Shutdown(ctx); err != nil {
643diff --git a/git/command.go b/git/command.go
644new file mode 100644
645index 0000000000000000000000000000000000000000..eb4f0d17ac5af0c7313cd200244a2bac36c04c13
646--- /dev/null
647+++ b/git/command.go
648@@ -0,0 +1,11 @@
649+package git
650+
651+import "github.com/gogs/git-module"
652+
653+// RunInDirOptions are options for RunInDir.
654+type RunInDirOptions = git.RunInDirOptions
655+
656+// NewCommand creates a new git command.
657+func NewCommand(args ...string) *git.Command {
658+ return git.NewCommand(args...)
659+}
660diff --git a/git/config.go b/git/config.go
661new file mode 100644
662index 0000000000000000000000000000000000000000..4e9af6ed500302e8d1e7e7afeabf13bbba4bde48
663--- /dev/null
664+++ b/git/config.go
665@@ -0,0 +1,51 @@
666+package git
667+
668+// ConfigOptions are options for Config.
669+type ConfigOptions struct {
670+ File string
671+ All bool
672+ Add bool
673+ CommandOptions
674+}
675+
676+// Config gets a git configuration.
677+func Config(key string, opts ...ConfigOptions) (string, error) {
678+ var opt ConfigOptions
679+ if len(opts) > 0 {
680+ opt = opts[0]
681+ }
682+ cmd := NewCommand("config")
683+ if opt.File != "" {
684+ cmd.AddArgs("--file", opt.File)
685+ }
686+ if opt.All {
687+ cmd.AddArgs("--get-all")
688+ }
689+ for _, a := range opt.Args {
690+ cmd.AddArgs(a)
691+ }
692+ cmd.AddArgs(key)
693+ bts, err := cmd.Run()
694+ if err != nil {
695+ return "", err
696+ }
697+ return string(bts), nil
698+}
699+
700+// SetConfig sets a git configuration.
701+func SetConfig(key string, value string, opts ...ConfigOptions) error {
702+ var opt ConfigOptions
703+ if len(opts) > 0 {
704+ opt = opts[0]
705+ }
706+ cmd := NewCommand("config")
707+ if opt.File != "" {
708+ cmd.AddArgs("--file", opt.File)
709+ }
710+ for _, a := range opt.Args {
711+ cmd.AddArgs(a)
712+ }
713+ cmd.AddArgs(key, value)
714+ _, err := cmd.Run()
715+ return err
716+}
717diff --git a/git/errors.go b/git/errors.go
718index e4c2ec35c8774584bdb34c1e0526cd8c4e66339c..40b0d390f3603c5168c9a4318229f8444869be44 100644
719--- a/git/errors.go
720+++ b/git/errors.go
721@@ -11,8 +11,8 @@ var (
722 ErrFileNotFound = errors.New("file not found")
723 // ErrDirectoryNotFound is returned when a directory is not found.
724 ErrDirectoryNotFound = errors.New("directory not found")
725- // ErrReferenceNotFound is returned when a reference is not found.
726- ErrReferenceNotFound = errors.New("reference not found")
727+ // ErrReferenceNotExist is returned when a reference does not exist.
728+ ErrReferenceNotExist = git.ErrReferenceNotExist
729 // ErrRevisionNotExist is returned when a revision is not found.
730 ErrRevisionNotExist = git.ErrRevisionNotExist
731 // ErrNotAGitRepository is returned when the given path is not a Git repository.
732diff --git a/git/repo.go b/git/repo.go
733index 9dd674032cdab567f48a64092386e78f5ac327c0..9d1f49d776e763fa2fb30be2c36fcc3f6259305f 100644
734--- a/git/repo.go
735+++ b/git/repo.go
736@@ -79,7 +79,7 @@ func (r *Repository) Name() string {
737
738 // HEAD returns the HEAD reference for a repository.
739 func (r *Repository) HEAD() (*Reference, error) {
740- rn, err := r.SymbolicRef()
741+ rn, err := r.Repository.SymbolicRef(git.SymbolicRefOptions{Name: "HEAD"})
742 if err != nil {
743 return nil, err
744 }
745@@ -212,3 +212,41 @@ func (r *Repository) UpdateServerInfo() error {
746 _, err := cmd.RunInDir(r.Path)
747 return err
748 }
749+
750+// Config returns the config value for the given key.
751+func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) {
752+ dir, err := gitDir(r.Repository)
753+ if err != nil {
754+ return "", err
755+ }
756+ var opt ConfigOptions
757+ if len(opts) > 0 {
758+ opt = opts[0]
759+ }
760+ opt.File = filepath.Join(dir, "config")
761+ return Config(key, opt)
762+}
763+
764+// SetConfig sets the config value for the given key.
765+func (r *Repository) SetConfig(key, value string, opts ...ConfigOptions) error {
766+ dir, err := gitDir(r.Repository)
767+ if err != nil {
768+ return err
769+ }
770+ var opt ConfigOptions
771+ if len(opts) > 0 {
772+ opt = opts[0]
773+ }
774+ opt.File = filepath.Join(dir, "config")
775+ return SetConfig(key, value, opt)
776+}
777+
778+// SymbolicRef returns or updates the symbolic reference for the given name.
779+// Both name and ref can be empty.
780+func (r *Repository) SymbolicRef(name string, ref string) (string, error) {
781+ opt := git.SymbolicRefOptions{
782+ Name: name,
783+ Ref: ref,
784+ }
785+ return r.Repository.SymbolicRef(opt)
786+}
787diff --git a/git/types.go b/git/types.go
788new file mode 100644
789index 0000000000000000000000000000000000000000..daf89b03eead9808ef30154605faa288ce4a3e19
790--- /dev/null
791+++ b/git/types.go
792@@ -0,0 +1,9 @@
793+package git
794+
795+import "github.com/gogs/git-module"
796+
797+// CommandOptions contain options for running a git command.
798+type CommandOptions = git.CommandOptions
799+
800+// CloneOptions contain options for cloning a repository.
801+type CloneOptions = git.CloneOptions
802diff --git a/go.mod b/go.mod
803index 3deb348a0d872d388d79dea5bb3ad0d3d863f8b8..5f3309903a6dcb74d512f8b67aad64ce0238261e 100644
804--- a/go.mod
805+++ b/go.mod
806@@ -9,10 +9,8 @@ require (
807 github.com/charmbracelet/bubbletea v0.23.2
808 github.com/charmbracelet/glamour v0.6.0
809 github.com/charmbracelet/lipgloss v0.7.1
810- github.com/charmbracelet/wish v0.7.0
811+ github.com/charmbracelet/wish v1.1.0
812 github.com/dustin/go-humanize v1.0.1
813- github.com/gliderlabs/ssh v0.3.5
814- github.com/go-git/go-billy/v5 v5.4.1
815 github.com/go-git/go-git/v5 v5.6.1
816 github.com/matryer/is v1.4.1
817 github.com/muesli/reflow v0.3.0
818@@ -22,39 +20,30 @@ require (
819
820 require (
821 github.com/aymanbagabas/go-osc52 v1.2.2
822- github.com/charmbracelet/keygen v0.3.0
823 github.com/charmbracelet/log v0.2.1
824+ github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
825 github.com/gobwas/glob v0.2.3
826 github.com/gogs/git-module v1.8.1
827- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
828 github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f
829 github.com/muesli/mango-cobra v1.2.0
830 github.com/muesli/roff v0.1.0
831 github.com/spf13/cobra v1.6.1
832 golang.org/x/crypto v0.7.0
833- gopkg.in/yaml.v3 v3.0.1
834+ golang.org/x/sync v0.1.0
835 )
836
837 require (
838- github.com/Microsoft/go-winio v0.5.2 // indirect
839- github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
840- github.com/acomagu/bufpipe v1.0.4 // indirect
841 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
842 github.com/atotto/clipboard v0.1.4 // indirect
843 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
844 github.com/aymerick/douceur v0.2.0 // indirect
845 github.com/caarlos0/sshmarshal v0.1.0 // indirect
846- github.com/cloudflare/circl v1.1.0 // indirect
847+ github.com/charmbracelet/keygen v0.3.0 // indirect
848 github.com/containerd/console v1.0.3 // indirect
849 github.com/dlclark/regexp2 v1.4.0 // indirect
850- github.com/emirpasic/gods v1.18.1 // indirect
851- github.com/go-git/gcfg v1.5.0 // indirect
852 github.com/go-logfmt/logfmt v0.6.0 // indirect
853 github.com/gorilla/css v1.0.0 // indirect
854- github.com/imdario/mergo v0.3.13 // indirect
855 github.com/inconshreveable/mousetrap v1.0.1 // indirect
856- github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
857- github.com/kevinburke/ssh_config v1.2.0 // indirect
858 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
859 github.com/mattn/go-isatty v0.0.18 // indirect
860 github.com/mattn/go-localereader v0.0.1 // indirect
861@@ -67,18 +56,13 @@ require (
862 github.com/muesli/mango v0.1.0 // indirect
863 github.com/muesli/mango-pflag v0.1.0 // indirect
864 github.com/olekukonko/tablewriter v0.0.5 // indirect
865- github.com/pjbgf/sha1cd v0.3.0 // indirect
866 github.com/rivo/uniseg v0.2.0 // indirect
867 github.com/sahilm/fuzzy v0.1.0 // indirect
868- github.com/skeema/knownhosts v1.1.0 // indirect
869 github.com/spf13/pflag v1.0.5 // indirect
870- github.com/xanzy/ssh-agent v0.3.3 // indirect
871 github.com/yuin/goldmark v1.5.2 // indirect
872 github.com/yuin/goldmark-emoji v1.0.1 // indirect
873 golang.org/x/net v0.8.0 // indirect
874- golang.org/x/sync v0.1.0 // indirect
875 golang.org/x/sys v0.6.0 // indirect
876 golang.org/x/term v0.6.0 // indirect
877 golang.org/x/text v0.8.0 // indirect
878- gopkg.in/warnings.v0 v0.1.2 // indirect
879 )
880diff --git a/go.sum b/go.sum
881index 9ecb2eaee4aba44c8f4e84adc250c0d8d4a170d4..626d920b1c3db6dedfe4381536bd047e47879539 100644
882--- a/go.sum
883+++ b/go.sum
884@@ -1,19 +1,10 @@
885-github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
886-github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
887-github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
888 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
889-github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
890-github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
891 github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
892-github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
893-github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
894 github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
895 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
896 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
897-github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
898 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
899 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
900-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
901 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
902 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
903 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
904@@ -46,11 +37,13 @@ github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0
905 github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
906 github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
907 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
908+github.com/charmbracelet/log v0.1.2/go.mod h1:86XdIdmrubqtL/6u0z+jGFol1bQejBGG/qPSTwGZuQQ=
909 github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE=
910 github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4=
911-github.com/charmbracelet/wish v0.7.0 h1:rdfacCWaKCQpCMPbOKfi68GYqsb+9CnUzN1Ov/INZJ0=
912-github.com/charmbracelet/wish v0.7.0/go.mod h1:16EQz7k3hEgPkPENghcpEddvlrmucIudE0jnczKr+k4=
913-github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY=
914+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE635jWwT1dISgfjbpUcEma+fbPKSMCU=
915+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM=
916+github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY=
917+github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo=
918 github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
919 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
920 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
921@@ -63,23 +56,12 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
922 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
923 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
924 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
925-github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
926-github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
927 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
928-github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
929-github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
930-github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
931 github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
932-github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
933 github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
934-github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
935 github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
936-github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
937 github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
938-github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
939-github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ=
940 github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
941-github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
942 github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk=
943 github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8=
944 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
945@@ -88,28 +70,17 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
946 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
947 github.com/gogs/git-module v1.8.1 h1:yC5BZ3unJOXC8N6/FgGQ8EtJXpOd217lgDcd2aPOxkc=
948 github.com/gogs/git-module v1.8.1/go.mod h1:Y3rsSqtFZEbn7lp+3gWf42GKIY1eNTtLt7JrmOy0yAQ=
949-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
950-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
951-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
952-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
953-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
954 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
955 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
956 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
957-github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
958-github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
959-github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
960+github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
961 github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
962 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
963 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
964 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
965-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
966 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
967 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
968-github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
969-github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
970 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
971-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
972 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
973 github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
974 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
975@@ -124,7 +95,6 @@ github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:Cxa
976 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
977 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
978 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
979-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
980 github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
981 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
982 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
983@@ -173,10 +143,7 @@ github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4AN
984diff --git a/server/backend/file/file.go b/server/backend/file/file.go
985index 9cdcd3e183e3214bb44ed90fc344c01f495f39ef..c29bc325833599241debe04b040bcdd383570c9c 100644
986--- a/server/backend/file/file.go
987+++ b/server/backend/file/file.go
988@@ -32,7 +32,7 @@ import (
989 "github.com/charmbracelet/log"
990 "github.com/charmbracelet/soft-serve/git"
991 "github.com/charmbracelet/soft-serve/server/backend"
992- "github.com/gliderlabs/ssh"
993+ "github.com/charmbracelet/ssh"
994 gitm "github.com/gogs/git-module"
995 gossh "golang.org/x/crypto/ssh"
996 )
997@@ -53,6 +53,14 @@ const (
998
999 var (
1000 logger = log.WithPrefix("backend.file")
1001+
1002+ defaults = map[string]string{
1003+ serverName: "Soft Serve",
1004+ serverHost: "localhost",
1005+ serverPort: "23231",
1006+ anonAccess: backend.ReadOnlyAccess.String(),
1007+ allowKeyless: "true",
1008+ }
1009 )
1010
1011 var _ backend.Backend = &FileBackend{}
1012@@ -114,8 +122,17 @@ func NewFileBackend(path string) (*FileBackend, error) {
1013 }
1014 }
1015 for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
1016- if _, err := os.OpenFile(filepath.Join(path, file), os.O_RDONLY|os.O_CREATE, 0644); err != nil {
1017- return nil, err
1018+ fp := filepath.Join(path, file)
1019+ _, err := os.Stat(fp)
1020+ if errors.Is(err, fs.ErrNotExist) {
1021+ f, err := os.Create(fp)
1022+ if err != nil {
1023+ return nil, err
1024+ }
1025+ if c, ok := defaults[file]; ok {
1026+ io.WriteString(f, c) // nolint:errcheck
1027+ }
1028+ _ = f.Close()
1029 }
1030 }
1031 return fb, nil
1032@@ -586,10 +603,7 @@ func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
1033 return err
1034 }
1035
1036- if _, err := r.SymbolicRef(gitm.SymbolicRefOptions{
1037- Name: "HEAD",
1038- Ref: gitm.RefsHeads + branch,
1039- }); err != nil {
1040+ if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
1041 logger.Debug("failed to set default branch", "err", err)
1042 return err
1043 }
1044diff --git a/server/backend/noop/noop.go b/server/backend/noop/noop.go
1045new file mode 100644
1046index 0000000000000000000000000000000000000000..c85e2b54bc07a3f1885b3fe9c7ddfdaae625c44f
1047--- /dev/null
1048+++ b/server/backend/noop/noop.go
1049@@ -0,0 +1,163 @@
1050+package noop
1051+
1052+import (
1053+ "fmt"
1054+ "os"
1055+ "path/filepath"
1056+
1057+ "github.com/charmbracelet/soft-serve/git"
1058+ "github.com/charmbracelet/soft-serve/server/backend"
1059+ "golang.org/x/crypto/ssh"
1060+)
1061+
1062+var ErrNotImpl = fmt.Errorf("not implemented")
1063+
1064+var _ backend.Backend = (*Noop)(nil)
1065+
1066+var _ backend.AccessMethod = (*Noop)(nil)
1067+
1068+// Noop is a backend that does nothing. It's used for testing.
1069+type Noop struct {
1070+ Port string
1071+}
1072+
1073+// AccessLevel implements backend.AccessMethod
1074+func (*Noop) AccessLevel(repo string, pk ssh.PublicKey) backend.AccessLevel {
1075+ return backend.AdminAccess
1076+}
1077+
1078+// AddAdmin implements backend.Backend
1079+func (*Noop) AddAdmin(pk ssh.PublicKey) error {
1080+ return ErrNotImpl
1081+}
1082+
1083+// AddCollaborator implements backend.Backend
1084+func (*Noop) AddCollaborator(pk ssh.PublicKey, repo string) error {
1085+ return ErrNotImpl
1086+}
1087+
1088+// AllowKeyless implements backend.Backend
1089+func (*Noop) AllowKeyless() bool {
1090+ return true
1091+}
1092+
1093+// AnonAccess implements backend.Backend
1094+func (*Noop) AnonAccess() backend.AccessLevel {
1095+ return backend.AdminAccess
1096+}
1097+
1098+// CreateRepository implements backend.Backend
1099+func (*Noop) CreateRepository(name string, private bool) (backend.Repository, error) {
1100+ temp, err := os.MkdirTemp("", "soft-serve")
1101+ if err != nil {
1102+ return nil, err
1103+ }
1104+
1105+ rp := filepath.Join(temp, name)
1106+ _, err = git.Init(rp, private)
1107+ if err != nil {
1108+ return nil, err
1109+ }
1110+
1111+ return &repo{path: rp}, nil
1112+}
1113+
1114+// DefaultBranch implements backend.Backend
1115+func (*Noop) DefaultBranch(repo string) (string, error) {
1116+ return "", ErrNotImpl
1117+}
1118+
1119+// DeleteRepository implements backend.Backend
1120+func (*Noop) DeleteRepository(name string) error {
1121+ return ErrNotImpl
1122+}
1123+
1124+// Description implements backend.Backend
1125+func (*Noop) Description(repo string) string {
1126+ return ""
1127+}
1128+
1129+// IsAdmin implements backend.Backend
1130+func (*Noop) IsAdmin(pk ssh.PublicKey) bool {
1131+ return true
1132+}
1133+
1134+// IsCollaborator implements backend.Backend
1135+func (*Noop) IsCollaborator(pk ssh.PublicKey, repo string) bool {
1136+ return true
1137+}
1138+
1139+// IsPrivate implements backend.Backend
1140+func (*Noop) IsPrivate(repo string) bool {
1141+ return false
1142+}
1143+
1144+// RenameRepository implements backend.Backend
1145+func (*Noop) RenameRepository(oldName string, newName string) error {
1146+ return ErrNotImpl
1147+}
1148+
1149diff --git a/server/backend/noop/repo.go b/server/backend/noop/repo.go
1150new file mode 100644
1151index 0000000000000000000000000000000000000000..80cbbcf2ba563b19833c793df96f42cbb36b58bf
1152--- /dev/null
1153+++ b/server/backend/noop/repo.go
1154@@ -0,0 +1,32 @@
1155+package noop
1156+
1157+import (
1158+ "github.com/charmbracelet/soft-serve/git"
1159+ "github.com/charmbracelet/soft-serve/server/backend"
1160+)
1161+
1162+var _ backend.Repository = (*repo)(nil)
1163+
1164+type repo struct {
1165+ path string
1166+}
1167+
1168+// Description implements backend.Repository
1169+func (*repo) Description() string {
1170+ return ""
1171+}
1172+
1173+// IsPrivate implements backend.Repository
1174+func (*repo) IsPrivate() bool {
1175+ return false
1176+}
1177+
1178+// Name implements backend.Repository
1179+func (*repo) Name() string {
1180+ return ""
1181+}
1182+
1183+// Repository implements backend.Repository
1184+func (r *repo) Repository() (*git.Repository, error) {
1185+ return git.Open(r.path)
1186+}
1187diff --git a/server/cmd/cat.go b/server/cmd/cat.go
1188deleted file mode 100644
1189index 75008918675b09849c19b662e83b0ccf6e205f34..0000000000000000000000000000000000000000
1190--- a/server/cmd/cat.go
1191+++ /dev/null
1192@@ -1,123 +0,0 @@
1193-package cmd
1194-
1195-import (
1196- "fmt"
1197- "strings"
1198-
1199- "github.com/alecthomas/chroma/lexers"
1200- gansi "github.com/charmbracelet/glamour/ansi"
1201- "github.com/charmbracelet/lipgloss"
1202- "github.com/charmbracelet/soft-serve/config"
1203- "github.com/charmbracelet/soft-serve/ui/common"
1204- gitwish "github.com/charmbracelet/wish/git"
1205- "github.com/muesli/termenv"
1206- "github.com/spf13/cobra"
1207-)
1208-
1209-var (
1210- lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
1211- lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
1212- dirnameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
1213- filenameStyle = lipgloss.NewStyle()
1214- filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
1215-)
1216-
1217-// CatCommand returns a command that prints the contents of a file.
1218-func CatCommand() *cobra.Command {
1219- var linenumber bool
1220- var color bool
1221-
1222- catCmd := &cobra.Command{
1223- Use: "cat PATH",
1224- Short: "Outputs the contents of the file at path.",
1225- Args: cobra.ExactArgs(1),
1226- RunE: func(cmd *cobra.Command, args []string) error {
1227- ac, s := fromContext(cmd)
1228- ps := strings.Split(args[0], "/")
1229- rn := ps[0]
1230- fp := strings.Join(ps[1:], "/")
1231- auth := ac.AuthRepo(rn, s.PublicKey())
1232- if auth < gitwish.ReadOnlyAccess {
1233- return ErrUnauthorized
1234- }
1235- var repo *config.Repo
1236- repoExists := false
1237- for _, rp := range ac.Source.AllRepos() {
1238- if rp.Repo() == rn {
1239- repoExists = true
1240- repo = rp
1241- break
1242- }
1243- }
1244- if !repoExists {
1245- return ErrRepoNotFound
1246- }
1247- c, _, err := repo.LatestFile(fp)
1248- if err != nil {
1249- return err
1250- }
1251- if color {
1252- c, err = withFormatting(fp, c)
1253- if err != nil {
1254- return err
1255- }
1256- }
1257- if linenumber {
1258- c = withLineNumber(c, color)
1259- }
1260- fmt.Fprint(s, c)
1261- return nil
1262- },
1263- }
1264- catCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
1265- catCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
1266-
1267- return catCmd
1268-}
1269-
1270-func withLineNumber(s string, color bool) string {
1271- lines := strings.Split(s, "\n")
1272- // NB: len() is not a particularly safe way to count string width (because
1273- // it's counting bytes instead of runes) but in this case it's okay
1274- // because we're only dealing with digits, which are one byte each.
1275- mll := len(fmt.Sprintf("%d", len(lines)))
1276- for i, l := range lines {
1277- digit := fmt.Sprintf("%*d", mll, i+1)
1278- bar := "│"
1279- if color {
1280- digit = lineDigitStyle.Render(digit)
1281- bar = lineBarStyle.Render(bar)
1282- }
1283- if i < len(lines)-1 || len(l) != 0 {
1284- // If the final line was a newline we'll get an empty string for
1285- // the final line, so drop the newline altogether.
1286- lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
1287- }
1288- }
1289- return strings.Join(lines, "\n")
1290-}
1291-
1292diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
1293index 684fe7ec65fb17559bceb05151208158b02bb134..00cfc3f26a8515c01344517fb607cd40dfa32b83 100644
1294--- a/server/cmd/cmd.go
1295+++ b/server/cmd/cmd.go
1296@@ -3,8 +3,8 @@ package cmd
1297 import (
1298 "fmt"
1299
1300- appCfg "github.com/charmbracelet/soft-serve/config"
1301- "github.com/gliderlabs/ssh"
1302+ "github.com/charmbracelet/soft-serve/server/config"
1303+ "github.com/charmbracelet/ssh"
1304 "github.com/spf13/cobra"
1305 )
1306
1307@@ -68,18 +68,15 @@ func RootCommand() *cobra.Command {
1308 rootCmd.SetUsageTemplate(usageTemplate)
1309 rootCmd.CompletionOptions.DisableDefaultCmd = true
1310 rootCmd.AddCommand(
1311- ReloadCommand(),
1312- CatCommand(),
1313- ListCommand(),
1314- GitCommand(),
1315+ RepoCommand(),
1316 )
1317
1318 return rootCmd
1319 }
1320
1321-func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) {
1322+func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
1323 ctx := cmd.Context()
1324- ac := ctx.Value(ConfigCtxKey).(*appCfg.Config)
1325+ cfg := ctx.Value(ConfigCtxKey).(*config.Config)
1326 s := ctx.Value(SessionCtxKey).(ssh.Session)
1327- return ac, s
1328+ return cfg, s
1329 }
1330diff --git a/server/cmd/git.go b/server/cmd/git.go
1331deleted file mode 100644
1332index d9a60070e6bdbf3ab3d1419d758bf93be75fc2c8..0000000000000000000000000000000000000000
1333--- a/server/cmd/git.go
1334+++ /dev/null
1335@@ -1,54 +0,0 @@
1336-package cmd
1337-
1338-import (
1339- "io"
1340- "os/exec"
1341-
1342- "github.com/charmbracelet/soft-serve/config"
1343- gitwish "github.com/charmbracelet/wish/git"
1344- "github.com/spf13/cobra"
1345-)
1346-
1347-// GitCommand returns a command that handles Git operations.
1348-func GitCommand() *cobra.Command {
1349- gitCmd := &cobra.Command{
1350- Use: "git REPO COMMAND",
1351- Short: "Perform Git operations on a repository.",
1352- RunE: func(cmd *cobra.Command, args []string) error {
1353- ac, s := fromContext(cmd)
1354- auth := ac.AuthRepo("config", s.PublicKey())
1355- if auth < gitwish.AdminAccess {
1356- return ErrUnauthorized
1357- }
1358- if len(args) < 1 {
1359- return runGit(nil, s, s, "")
1360- }
1361- var repo *config.Repo
1362- rn := args[0]
1363- repoExists := false
1364- for _, rp := range ac.Source.AllRepos() {
1365- if rp.Repo() == rn {
1366- repoExists = true
1367- repo = rp
1368- break
1369- }
1370- }
1371- if !repoExists {
1372- return ErrRepoNotFound
1373- }
1374- return runGit(nil, s, s, repo.Path(), args[1:]...)
1375- },
1376- }
1377- gitCmd.Flags().SetInterspersed(false)
1378-
1379- return gitCmd
1380-}
1381-
1382-func runGit(in io.Reader, out, err io.Writer, dir string, args ...string) error {
1383- cmd := exec.Command("git", args...)
1384- cmd.Stdin = in
1385- cmd.Stdout = out
1386- cmd.Stderr = err
1387- cmd.Dir = dir
1388- return cmd.Run()
1389-}
1390diff --git a/server/cmd/list.go b/server/cmd/list.go
1391deleted file mode 100644
1392index ff4045c5b1f8a3fbbe6c2f6ca8eea42e928c7f34..0000000000000000000000000000000000000000
1393--- a/server/cmd/list.go
1394+++ /dev/null
1395@@ -1,83 +0,0 @@
1396-package cmd
1397-
1398-import (
1399- "fmt"
1400- "path/filepath"
1401- "strings"
1402-
1403- "github.com/charmbracelet/soft-serve/git"
1404- gitwish "github.com/charmbracelet/wish/git"
1405- "github.com/spf13/cobra"
1406-)
1407-
1408-// ListCommand returns a command that list file or directory at path.
1409-func ListCommand() *cobra.Command {
1410- lsCmd := &cobra.Command{
1411- Use: "ls PATH",
1412- Aliases: []string{"list"},
1413- Short: "List file or directory at path.",
1414- Args: cobra.RangeArgs(0, 1),
1415- RunE: func(cmd *cobra.Command, args []string) error {
1416- ac, s := fromContext(cmd)
1417- rn := ""
1418- path := ""
1419- ps := []string{}
1420- if len(args) > 0 {
1421- path = filepath.Clean(args[0])
1422- ps = strings.Split(path, "/")
1423- rn = ps[0]
1424- auth := ac.AuthRepo(rn, s.PublicKey())
1425- if auth < gitwish.ReadOnlyAccess {
1426- return ErrUnauthorized
1427- }
1428- }
1429- if path == "" || path == "." || path == "/" {
1430- for _, r := range ac.Source.AllRepos() {
1431- if ac.AuthRepo(r.Repo(), s.PublicKey()) >= gitwish.ReadOnlyAccess {
1432- fmt.Fprintln(s, r.Repo())
1433- }
1434- }
1435- return nil
1436- }
1437- r, err := ac.Source.GetRepo(rn)
1438- if err != nil {
1439- return err
1440- }
1441- head, err := r.HEAD()
1442- if err != nil {
1443- return err
1444- }
1445- tree, err := r.Tree(head, "")
1446- if err != nil {
1447- return err
1448- }
1449- subpath := strings.Join(ps[1:], "/")
1450- ents := git.Entries{}
1451- te, err := tree.TreeEntry(subpath)
1452- if err == git.ErrRevisionNotExist {
1453- return ErrFileNotFound
1454- }
1455- if err != nil {
1456- return err
1457- }
1458- if te.Type() == "tree" {
1459- tree, err = tree.SubTree(subpath)
1460- if err != nil {
1461- return err
1462- }
1463- ents, err = tree.Entries()
1464- if err != nil {
1465- return err
1466- }
1467- } else {
1468- ents = append(ents, te)
1469- }
1470- ents.Sort()
1471- for _, ent := range ents {
1472- fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
1473- }
1474- return nil
1475- },
1476- }
1477- return lsCmd
1478-}
1479diff --git a/server/middleware.go b/server/cmd/middleware.go
1480rename from server/middleware.go
1481rename to server/cmd/middleware.go
1482index a4969f5fe5582929bc7ed6ddca571d225c4514d0..8635a66507a980fe3cb5b292fc682d082f5767dd 100644
1483--- a/server/middleware.go
1484+++ b/server/cmd/middleware.go
1485@@ -1,17 +1,16 @@
1486-package server
1487+package cmd
1488
1489 import (
1490 "context"
1491 "fmt"
1492
1493- appCfg "github.com/charmbracelet/soft-serve/config"
1494- "github.com/charmbracelet/soft-serve/server/cmd"
1495+ "github.com/charmbracelet/soft-serve/server/config"
1496+ "github.com/charmbracelet/ssh"
1497 "github.com/charmbracelet/wish"
1498- "github.com/gliderlabs/ssh"
1499 )
1500
1501-// softMiddleware is the Soft Serve middleware that handles SSH commands.
1502-func softMiddleware(ac *appCfg.Config) wish.Middleware {
1503+// Middleware is the Soft Serve middleware that handles SSH commands.
1504+func Middleware(cfg *config.Config) wish.Middleware {
1505 return func(sh ssh.Handler) ssh.Handler {
1506 return func(s ssh.Session) {
1507 func() {
1508@@ -19,16 +18,16 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware {
1509 if active {
1510 return
1511 }
1512- ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac)
1513- ctx = context.WithValue(ctx, cmd.SessionCtxKey, s)
1514+ ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
1515+ ctx = context.WithValue(ctx, SessionCtxKey, s)
1516
1517 use := "ssh"
1518- port := ac.Port
1519- if port != 22 {
1520- use += fmt.Sprintf(" -p%d", port)
1521+ port := cfg.Backend.ServerPort()
1522+ if port != "22" {
1523+ use += fmt.Sprintf(" -p%s", port)
1524 }
1525- use += fmt.Sprintf(" %s", ac.Host)
1526- cmd := cmd.RootCommand()
1527+ use += fmt.Sprintf(" %s", cfg.Backend.ServerHost())
1528+ cmd := RootCommand()
1529 cmd.Use = use
1530 cmd.CompletionOptions.DisableDefaultCmd = true
1531 cmd.SetIn(s)
1532diff --git a/server/cmd/reload.go b/server/cmd/reload.go
1533deleted file mode 100644
1534index 7f2312ab6e550109e9c52d0ee9c0ef80f3fa1253..0000000000000000000000000000000000000000
1535--- a/server/cmd/reload.go
1536+++ /dev/null
1537@@ -1,23 +0,0 @@
1538-package cmd
1539-
1540-import (
1541- gitwish "github.com/charmbracelet/wish/git"
1542- "github.com/spf13/cobra"
1543-)
1544-
1545-// ReloadCommand returns a command that reloads the server configuration.
1546-func ReloadCommand() *cobra.Command {
1547- reloadCmd := &cobra.Command{
1548- Use: "reload",
1549- Short: "Reloads the configuration",
1550- RunE: func(cmd *cobra.Command, args []string) error {
1551- ac, s := fromContext(cmd)
1552- auth := ac.AuthRepo("config", s.PublicKey())
1553- if auth < gitwish.AdminAccess {
1554- return ErrUnauthorized
1555- }
1556- return ac.Reload()
1557- },
1558- }
1559- return reloadCmd
1560-}
1561diff --git a/server/cmd/repo.go b/server/cmd/repo.go
1562new file mode 100644
1563index 0000000000000000000000000000000000000000..c2324bee76cc0f0b919d19c499d377df90be3f43
1564--- /dev/null
1565+++ b/server/cmd/repo.go
1566@@ -0,0 +1,408 @@
1567+package cmd
1568+
1569+import (
1570+ "fmt"
1571+ "path/filepath"
1572+ "strconv"
1573+ "strings"
1574+
1575+ "github.com/alecthomas/chroma/lexers"
1576+ gansi "github.com/charmbracelet/glamour/ansi"
1577+ "github.com/charmbracelet/lipgloss"
1578+ "github.com/charmbracelet/soft-serve/git"
1579+ "github.com/charmbracelet/soft-serve/server/backend"
1580+ "github.com/charmbracelet/soft-serve/ui/common"
1581+ "github.com/muesli/termenv"
1582+ "github.com/spf13/cobra"
1583+)
1584+
1585+// RepoCommand is the command for managing repositories.
1586+func RepoCommand() *cobra.Command {
1587+ cmd := &cobra.Command{
1588+ Use: "repo COMMAND",
1589+ Aliases: []string{"repository", "repositories"},
1590+ Short: "Manage repositories.",
1591+ }
1592+ cmd.AddCommand(
1593+ setCommand(),
1594+ createCommand(),
1595+ deleteCommand(),
1596+ listCommand(),
1597+ showCommand(),
1598+ )
1599+ return cmd
1600+}
1601+
1602+func setCommand() *cobra.Command {
1603+ cmd := &cobra.Command{
1604+ Use: "set",
1605+ Short: "Set repository properties.",
1606+ }
1607+ cmd.AddCommand(
1608+ setName(),
1609+ setDescription(),
1610+ setPrivate(),
1611+ setDefaultBranch(),
1612+ )
1613+ return cmd
1614+}
1615+
1616+// createCommand is the command for creating a new repository.
1617+func createCommand() *cobra.Command {
1618+ var private bool
1619+ var description string
1620+ var projectName string
1621+ cmd := &cobra.Command{
1622+ Use: "create REPOSITORY",
1623+ Short: "Create a new repository.",
1624+ Args: cobra.ExactArgs(1),
1625+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
1626+ cfg, s := fromContext(cmd)
1627+ if !cfg.Backend.IsAdmin(s.PublicKey()) {
1628+ return ErrUnauthorized
1629+ }
1630+ return nil
1631+ },
1632+ RunE: func(cmd *cobra.Command, args []string) error {
1633+ cfg, _ := fromContext(cmd)
1634+ name := args[0]
1635+ if _, err := cfg.Backend.CreateRepository(name, private); err != nil {
1636+ return err
1637+ }
1638+ return nil
1639+ },
1640+ }
1641+ cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
1642+ cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
1643+ cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name")
1644+ return cmd
1645+}
1646+
1647+func deleteCommand() *cobra.Command {
1648+ cmd := &cobra.Command{
1649+ Use: "delete REPOSITORY",
1650+ Short: "Delete a repository.",
1651+ Args: cobra.ExactArgs(1),
1652+ PersistentPreRunE: checkIfAdmin,
1653+ RunE: func(cmd *cobra.Command, args []string) error {
1654+ cfg, _ := fromContext(cmd)
1655+ name := args[0]
1656+ if err := cfg.Backend.DeleteRepository(name); err != nil {
1657+ return err
1658+ }
1659+ return nil
1660+ },
1661+ }
1662+ return cmd
1663+}
1664+
1665+func checkIfReadable(cmd *cobra.Command, args []string) error {
1666diff --git a/server/config/config.go b/server/config/config.go
1667index 69b3a7098670d2655ce915b86606f3dee7510e54..14dcff657128d87749f1ea4d3df56049eeb6525b 100644
1668--- a/server/config/config.go
1669+++ b/server/config/config.go
1670@@ -1,58 +1,93 @@
1671 package config
1672
1673 import (
1674- glog "log"
1675- "path/filepath"
1676-
1677 "github.com/caarlos0/env/v6"
1678 "github.com/charmbracelet/log"
1679+ "github.com/charmbracelet/soft-serve/server/backend"
1680+ "github.com/charmbracelet/soft-serve/server/backend/file"
1681 )
1682
1683-// Callbacks provides an interface that can be used to run callbacks on different events.
1684-type Callbacks interface {
1685- Tui(action string)
1686- Push(repo string)
1687- Fetch(repo string)
1688+// SSHConfig is the configuration for the SSH server.
1689+type SSHConfig struct {
1690+ // ListenAddr is the address on which the SSH server will listen.
1691+ ListenAddr string `env:"LISTEN_ADDR" envDefault:":23231"`
1692+
1693+ // KeyPath is the path to the SSH server's private key.
1694+ KeyPath string `env:"KEY_PATH" envDefault:"soft_serve"`
1695+
1696+ // MaxTimeout is the maximum number of seconds a connection can take.
1697+ MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
1698+
1699+ // IdleTimeout is the number of seconds a connection can be idle before it is closed.
1700+ IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"120"`
1701+}
1702+
1703+// GitConfig is the Git daemon configuration for the server.
1704+type GitConfig struct {
1705+ // ListenAddr is the address on which the Git daemon will listen.
1706+ ListenAddr string `env:"LISTEN_ADDR" envDefault:":9418"`
1707+
1708+ // MaxTimeout is the maximum number of seconds a connection can take.
1709+ MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
1710+
1711+ // IdleTimeout is the number of seconds a connection can be idle before it is closed.
1712+ IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"3"`
1713+
1714+ // MaxConnections is the maximum number of concurrent connections.
1715+ MaxConnections int `env:"MAX_CONNECTIONS" envDefault:"32"`
1716 }
1717
1718 // Config is the configuration for Soft Serve.
1719 type Config struct {
1720- BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
1721- Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
1722- Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"`
1723- KeyPath string `env:"SOFT_SERVE_KEY_PATH"`
1724- RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
1725- Debug bool `env:"SOFT_SERVE_DEBUG" envDefault:"false"`
1726- InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
1727- Callbacks Callbacks
1728- ErrorLog *glog.Logger
1729+ // SSH is the configuration for the SSH server.
1730+ SSH SSHConfig `envPrefix:"SSH_"`
1731+
1732+ // Git is the configuration for the Git daemon.
1733+ Git GitConfig `envPrefix:"GIT_"`
1734+
1735+ // InitialAdminKeys is a list of public keys that will be added to the list of admins.
1736+ InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"`
1737+
1738+ // DataPath is the path to the directory where Soft Serve will store its data.
1739+ DataPath string `env:"DATA_PATH" envDefault:"data"`
1740+
1741+ // Debug enables debug logging.
1742+ Debug bool `env:"DEBUG" envDefault:"false"`
1743+
1744+ // Backend is the Git backend to use.
1745+ Backend backend.Backend
1746+
1747+ // Access is the access control backend to use.
1748+ Access backend.AccessMethod
1749 }
1750
1751 // DefaultConfig returns a Config with the values populated with the defaults
1752 // or specified environment variables.
1753 func DefaultConfig() *Config {
1754- cfg := &Config{ErrorLog: log.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel})}
1755- if err := env.Parse(cfg); err != nil {
1756+ cfg := &Config{}
1757+ if err := env.Parse(cfg, env.Options{
1758+ Prefix: "SOFT_SERVE_",
1759+ }); err != nil {
1760 log.Fatal(err)
1761 }
1762 if cfg.Debug {
1763 log.SetLevel(log.DebugLevel)
1764 }
1765- if cfg.KeyPath == "" {
1766- // NB: cross-platform-compatible path
1767- cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
1768+ fb, err := file.NewFileBackend(cfg.DataPath)
1769+ if err != nil {
1770diff --git a/server/daemon.go b/server/daemon.go
1771new file mode 100644
1772index 0000000000000000000000000000000000000000..0055ff9c6ce05726dbdb39d3e51eaeabc5935be0
1773--- /dev/null
1774+++ b/server/daemon.go
1775@@ -0,0 +1,299 @@
1776+package server
1777+
1778+import (
1779+ "bytes"
1780+ "context"
1781+ "errors"
1782+ "io"
1783+ "log"
1784+ "net"
1785+ "path/filepath"
1786+ "strings"
1787+ "sync"
1788+ "time"
1789+
1790+ "github.com/charmbracelet/soft-serve/server/backend"
1791+ "github.com/charmbracelet/soft-serve/server/config"
1792+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
1793+)
1794+
1795+// ErrServerClosed indicates that the server has been closed.
1796+var ErrServerClosed = errors.New("git: Server closed")
1797+
1798+// connections synchronizes access to to a net.Conn pool.
1799+type connections struct {
1800+ m map[net.Conn]struct{}
1801+ mu sync.Mutex
1802+}
1803+
1804+func (m *connections) Add(c net.Conn) {
1805+ m.mu.Lock()
1806+ defer m.mu.Unlock()
1807+ m.m[c] = struct{}{}
1808+}
1809+
1810+func (m *connections) Close(c net.Conn) {
1811+ m.mu.Lock()
1812+ defer m.mu.Unlock()
1813+ _ = c.Close()
1814+ delete(m.m, c)
1815+}
1816+
1817+func (m *connections) Size() int {
1818+ m.mu.Lock()
1819+ defer m.mu.Unlock()
1820+ return len(m.m)
1821+}
1822+
1823+func (m *connections) CloseAll() {
1824+ m.mu.Lock()
1825+ defer m.mu.Unlock()
1826+ for c := range m.m {
1827+ _ = c.Close()
1828+ delete(m.m, c)
1829+ }
1830+}
1831+
1832+// GitDaemon represents a Git daemon.
1833+type GitDaemon struct {
1834+ listener net.Listener
1835+ addr string
1836+ finished chan struct{}
1837+ conns connections
1838+ cfg *config.Config
1839+ wg sync.WaitGroup
1840+ once sync.Once
1841+}
1842+
1843+// NewDaemon returns a new Git daemon.
1844+func NewGitDaemon(cfg *config.Config) (*GitDaemon, error) {
1845+ addr := cfg.Git.ListenAddr
1846+ d := &GitDaemon{
1847+ addr: addr,
1848+ finished: make(chan struct{}, 1),
1849+ cfg: cfg,
1850+ conns: connections{m: make(map[net.Conn]struct{})},
1851+ }
1852+ listener, err := net.Listen("tcp", d.addr)
1853+ if err != nil {
1854+ return nil, err
1855+ }
1856+ d.listener = listener
1857+ return d, nil
1858+}
1859+
1860+// Start starts the Git TCP daemon.
1861+func (d *GitDaemon) Start() error {
1862+ defer d.listener.Close() // nolint: errcheck
1863+
1864+ d.wg.Add(1)
1865+ defer d.wg.Done()
1866+
1867+ var tempDelay time.Duration
1868+ for {
1869+ conn, err := d.listener.Accept()
1870+ if err != nil {
1871+ select {
1872+ case <-d.finished:
1873+ return ErrServerClosed
1874+ default:
1875diff --git a/server/daemon_test.go b/server/daemon_test.go
1876new file mode 100644
1877index 0000000000000000000000000000000000000000..e2e059e42cd6553cf5b707a386061204a1852b98
1878--- /dev/null
1879+++ b/server/daemon_test.go
1880@@ -0,0 +1,95 @@
1881+package server
1882+
1883+import (
1884+ "bytes"
1885+ "errors"
1886+ "fmt"
1887+ "io"
1888+ "log"
1889+ "net"
1890+ "os"
1891+ "strings"
1892+ "testing"
1893+ "time"
1894+
1895+ "github.com/charmbracelet/soft-serve/server/config"
1896+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
1897+)
1898+
1899+var testDaemon *GitDaemon
1900+
1901+func TestMain(m *testing.M) {
1902+ tmp, err := os.MkdirTemp("", "soft-serve-test")
1903+ if err != nil {
1904+ log.Fatal(err)
1905+ }
1906+ defer os.RemoveAll(tmp)
1907+ os.Setenv("SOFT_SERVE_DATA_PATH", tmp)
1908+ os.Setenv("SOFT_SERVE_GIT_MAX_CONNECTIONS", "3")
1909+ os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
1910+ os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
1911+ os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
1912+ cfg := config.DefaultConfig()
1913+ d, err := NewGitDaemon(cfg)
1914+ if err != nil {
1915+ log.Fatal(err)
1916+ }
1917+ testDaemon = d
1918+ go func() {
1919+ if err := d.Start(); err != ErrServerClosed {
1920+ log.Fatal(err)
1921+ }
1922+ }()
1923+ code := m.Run()
1924+ os.Unsetenv("SOFT_SERVE_DATA_PATH")
1925+ os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS")
1926+ os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT")
1927+ os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
1928+ os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")
1929+ _ = d.Close()
1930+ os.Exit(code)
1931+}
1932+
1933+func TestIdleTimeout(t *testing.T) {
1934+ c, err := net.Dial("tcp", testDaemon.addr)
1935+ if err != nil {
1936+ t.Fatal(err)
1937+ }
1938+ time.Sleep(2 * time.Second)
1939+ out, err := readPktline(c)
1940+ if err != nil && !errors.Is(err, io.EOF) {
1941+ t.Fatalf("expected nil, got error: %v", err)
1942+ }
1943+ if out != ErrTimeout.Error() {
1944+ t.Fatalf("expected %q error, got %q", ErrTimeout, out)
1945+ }
1946+}
1947+
1948+func TestInvalidRepo(t *testing.T) {
1949+ c, err := net.Dial("tcp", testDaemon.addr)
1950+ if err != nil {
1951+ t.Fatal(err)
1952+ }
1953+ if err := pktline.NewEncoder(c).EncodeString("git-upload-pack /test.git\x00"); err != nil {
1954+ t.Fatalf("expected nil, got error: %v", err)
1955+ }
1956+ out, err := readPktline(c)
1957+ if err != nil {
1958+ t.Fatalf("expected nil, got error: %v", err)
1959+ }
1960+ if out != ErrInvalidRepo.Error() {
1961+ t.Fatalf("expected %q error, got %q", ErrInvalidRepo, out)
1962+ }
1963+}
1964+
1965+func readPktline(c net.Conn) (string, error) {
1966+ buf, err := io.ReadAll(c)
1967+ if err != nil {
1968+ return "", err
1969+ }
1970+ pktout := pktline.NewScanner(bytes.NewReader(buf))
1971+ if !pktout.Scan() {
1972+ return "", pktout.Err()
1973+ }
1974+ return strings.TrimSpace(string(pktout.Bytes())), nil
1975+}
1976diff --git a/server/git.go b/server/git.go
1977new file mode 100644
1978index 0000000000000000000000000000000000000000..812dddfc46c37f6952bcd2bfebaf7286a16d129e
1979--- /dev/null
1980+++ b/server/git.go
1981@@ -0,0 +1,162 @@
1982+package server
1983+
1984+import (
1985+ "errors"
1986+ "fmt"
1987+ "io"
1988+ "log"
1989+ "os"
1990+ "path/filepath"
1991+
1992+ "github.com/charmbracelet/soft-serve/git"
1993+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
1994+)
1995+
1996+var (
1997+
1998+ // ErrNotAuthed represents unauthorized access.
1999+ ErrNotAuthed = errors.New("you are not authorized to do this")
2000+
2001+ // ErrSystemMalfunction represents a general system error returned to clients.
2002+ ErrSystemMalfunction = errors.New("something went wrong")
2003+
2004+ // ErrInvalidRepo represents an attempt to access a non-existent repo.
2005+ ErrInvalidRepo = errors.New("invalid repo")
2006+
2007+ // ErrInvalidRequest represents an invalid request.
2008+ ErrInvalidRequest = errors.New("invalid request")
2009+
2010+ // ErrMaxConnections represents a maximum connection limit being reached.
2011+ ErrMaxConnections = errors.New("too many connections, try again later")
2012+
2013+ // ErrTimeout is returned when the maximum read timeout is exceeded.
2014+ ErrTimeout = errors.New("I/O timeout reached")
2015+)
2016+
2017+// Git protocol commands.
2018+const (
2019+ ReceivePackBin = "git-receive-pack"
2020+ UploadPackBin = "git-upload-pack"
2021+ UploadArchiveBin = "git-upload-archive"
2022+)
2023+
2024+// UploadPack runs the git upload-pack protocol against the provided repo.
2025+func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
2026+ exists, err := fileExists(repoDir)
2027+ if !exists {
2028+ return ErrInvalidRepo
2029+ }
2030+ if err != nil {
2031+ return err
2032+ }
2033+ return RunGit(in, out, er, "", UploadPackBin[4:], repoDir)
2034+}
2035+
2036+// UploadArchive runs the git upload-archive protocol against the provided repo.
2037+func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
2038+ exists, err := fileExists(repoDir)
2039+ if !exists {
2040+ return ErrInvalidRepo
2041+ }
2042+ if err != nil {
2043+ return err
2044+ }
2045+ return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir)
2046+}
2047+
2048+// ReceivePack runs the git receive-pack protocol against the provided repo.
2049+func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
2050+ if err := ensureRepo(repoDir, ""); err != nil {
2051+ return err
2052+ }
2053+ if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
2054+ return err
2055+ }
2056+ return ensureDefaultBranch(in, out, er, repoDir)
2057+}
2058+
2059+// RunGit runs a git command in the given repo.
2060+func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
2061+ c := git.NewCommand(args...)
2062+ return c.RunInDirWithOptions(dir, git.RunInDirOptions{
2063+ Stdin: in,
2064+ Stdout: out,
2065+ Stderr: err,
2066+ })
2067+}
2068+
2069+// WritePktline encodes and writes a pktline to the given writer.
2070+func WritePktline(w io.Writer, v ...interface{}) {
2071+ msg := fmt.Sprintln(v...)
2072+ pkt := pktline.NewEncoder(w)
2073+ if err := pkt.EncodeString(msg); err != nil {
2074+ log.Printf("git: error writing pkt-line message: %s", err)
2075+ }
2076+ if err := pkt.Flush(); err != nil {
2077+ log.Printf("git: error flushing pkt-line message: %s", err)
2078+ }
2079+}
2080+
2081diff --git a/server/git/daemon/conn.go b/server/git/daemon/conn.go
2082new file mode 100644
2083index 0000000000000000000000000000000000000000..1ab35242405bc4c2cad2673eec3091cead55213f
2084--- /dev/null
2085+++ b/server/git/daemon/conn.go
2086@@ -0,0 +1,55 @@
2087+package daemon
2088+
2089+import (
2090+ "context"
2091+ "net"
2092+ "time"
2093+)
2094+
2095+type serverConn struct {
2096+ net.Conn
2097+
2098+ idleTimeout time.Duration
2099+ maxDeadline time.Time
2100+ closeCanceler context.CancelFunc
2101+}
2102+
2103+func (c *serverConn) Write(p []byte) (n int, err error) {
2104+ c.updateDeadline()
2105+ n, err = c.Conn.Write(p)
2106+ if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
2107+ c.closeCanceler()
2108+ }
2109+ return
2110+}
2111+
2112+func (c *serverConn) Read(b []byte) (n int, err error) {
2113+ c.updateDeadline()
2114+ n, err = c.Conn.Read(b)
2115+ if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
2116+ c.closeCanceler()
2117+ }
2118+ return
2119+}
2120+
2121+func (c *serverConn) Close() (err error) {
2122+ err = c.Conn.Close()
2123+ if c.closeCanceler != nil {
2124+ c.closeCanceler()
2125+ }
2126+ return
2127+}
2128+
2129+func (c *serverConn) updateDeadline() {
2130+ switch {
2131+ case c.idleTimeout > 0:
2132+ idleDeadline := time.Now().Add(c.idleTimeout)
2133+ if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
2134+ c.Conn.SetDeadline(idleDeadline)
2135+ return
2136+ }
2137+ fallthrough
2138+ default:
2139+ c.Conn.SetDeadline(c.maxDeadline)
2140+ }
2141+}
2142diff --git a/server/middleware_test.go b/server/middleware_test.go
2143deleted file mode 100644
2144index 6895d3d10d9483fb0b202973a84e388a5f8a6bc8..0000000000000000000000000000000000000000
2145--- a/server/middleware_test.go
2146+++ /dev/null
2147@@ -1,38 +0,0 @@
2148-package server
2149-
2150-import (
2151- "os"
2152- "testing"
2153-
2154- "github.com/charmbracelet/soft-serve/config"
2155- sconfig "github.com/charmbracelet/soft-serve/server/config"
2156- "github.com/charmbracelet/wish/testsession"
2157- "github.com/gliderlabs/ssh"
2158- "github.com/matryer/is"
2159-)
2160-
2161-var ()
2162-
2163-func TestMiddleware(t *testing.T) {
2164- t.Cleanup(func() {
2165- os.RemoveAll("testmiddleware")
2166- })
2167- is := is.New(t)
2168- appCfg, err := config.NewConfig(&sconfig.Config{
2169- Host: "localhost",
2170- Port: 22223,
2171- RepoPath: "testmiddleware/repos",
2172- KeyPath: "testmiddleware/key",
2173- })
2174- is.NoErr(err)
2175- _ = testsession.New(t, &ssh.Server{
2176- Handler: softMiddleware(appCfg)(func(s ssh.Session) {
2177- t.Run("TestCatConfig", func(t *testing.T) {
2178- _, err := s.Write([]byte("cat config/config.json"))
2179- if err == nil {
2180- t.Errorf("Expected error, got nil")
2181- }
2182- })
2183- }),
2184- }, nil)
2185-}
2186diff --git a/server/server.go b/server/server.go
2187index 1ba07f7f8887a10a06a43b4c31356a6f9e0b4b6a..b52d09f28c08f5c71f3621055f0f2166307cc1ca 100644
2188--- a/server/server.go
2189+++ b/server/server.go
2190@@ -2,29 +2,26 @@ package server
2191
2192 import (
2193 "context"
2194- "fmt"
2195- "net"
2196- "path/filepath"
2197- "strings"
2198
2199 "github.com/charmbracelet/log"
2200
2201- appCfg "github.com/charmbracelet/soft-serve/config"
2202+ "github.com/charmbracelet/soft-serve/server/backend"
2203 "github.com/charmbracelet/soft-serve/server/config"
2204- "github.com/charmbracelet/wish"
2205- bm "github.com/charmbracelet/wish/bubbletea"
2206- gm "github.com/charmbracelet/wish/git"
2207- lm "github.com/charmbracelet/wish/logging"
2208- rm "github.com/charmbracelet/wish/recover"
2209- "github.com/gliderlabs/ssh"
2210- "github.com/muesli/termenv"
2211+ "github.com/charmbracelet/ssh"
2212+ "golang.org/x/sync/errgroup"
2213+)
2214+
2215+var (
2216+ logger = log.WithPrefix("server")
2217 )
2218
2219 // Server is the Soft Serve server.
2220 type Server struct {
2221- SSHServer *ssh.Server
2222+ SSHServer *SSHServer
2223+ GitDaemon *GitDaemon
2224 Config *config.Config
2225- config *appCfg.Config
2226+ Backend backend.Backend
2227+ Access backend.AccessMethod
2228 }
2229
2230 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
2231@@ -32,85 +29,66 @@ type Server struct {
2232 // key can be provided with authKey. If authKey is provided, access will be
2233 // restricted to that key. If authKey is not provided, the server will be
2234 // publicly writable until configured otherwise by cloning the `config` repo.
2235-func NewServer(cfg *config.Config) *Server {
2236- ac, err := appCfg.NewConfig(cfg)
2237- if err != nil {
2238- log.Fatal(err)
2239+func NewServer(cfg *config.Config) (*Server, error) {
2240+ var err error
2241+ srv := &Server{
2242+ Config: cfg,
2243+ Backend: cfg.Backend,
2244+ Access: cfg.Access,
2245 }
2246- mw := []wish.Middleware{
2247- rm.MiddlewareWithLogger(
2248- cfg.ErrorLog,
2249- softMiddleware(ac),
2250- bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
2251- gm.Middleware(cfg.RepoPath, ac),
2252- // Note: disable pushing to subdirectories as it can create
2253- // conflicts with existing repos. This only affects the git
2254- // middleware.
2255- //
2256- // This is related to
2257- // https://github.com/charmbracelet/soft-serve/issues/120
2258- // https://github.com/charmbracelet/wish/commit/8808de520d3ea21931f13113c6b0b6d0141272d4
2259- func(sh ssh.Handler) ssh.Handler {
2260- return func(s ssh.Session) {
2261- cmds := s.Command()
2262- if len(cmds) == 2 && strings.HasPrefix(cmds[0], "git") {
2263- repo := strings.TrimSuffix(strings.TrimPrefix(cmds[1], "/"), "/")
2264- repo = filepath.Clean(repo)
2265- if n := strings.Count(repo, "/"); n != 0 {
2266- wish.Fatalln(s, fmt.Errorf("invalid repo path: subdirectories not allowed"))
2267- return
2268- }
2269- }
2270- sh(s)
2271- }
2272- },
2273- lm.MiddlewareWithLogger(log.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})),
2274- ),
2275- }
2276- s, err := wish.NewServer(
2277- ssh.PublicKeyAuth(ac.PublicKeyHandler),
2278- ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler),
2279- wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)),
2280- wish.WithHostKeyPath(cfg.KeyPath),
2281- wish.WithMiddleware(mw...),
2282- )
2283+ srv.SSHServer, err = NewSSHServer(cfg)
2284 if err != nil {
2285- log.Fatal(err)
2286+ return nil, err
2287 }
2288- return &Server{
2289- SSHServer: s,
2290diff --git a/server/server_test.go b/server/server_test.go
2291index 6f126ce9bbd81b60f608d936f05996d57914339f..b3bdb9ebbafb89ac7987fb2744c01e3f4c41896b 100644
2292--- a/server/server_test.go
2293+++ b/server/server_test.go
2294@@ -2,116 +2,64 @@ package server
2295
2296 import (
2297 "fmt"
2298- "os"
2299+ "net"
2300 "path/filepath"
2301+ "strings"
2302 "testing"
2303
2304 "github.com/charmbracelet/keygen"
2305+ "github.com/charmbracelet/soft-serve/server/backend/noop"
2306 "github.com/charmbracelet/soft-serve/server/config"
2307- "github.com/gliderlabs/ssh"
2308- "github.com/go-git/go-git/v5"
2309- gconfig "github.com/go-git/go-git/v5/config"
2310- "github.com/go-git/go-git/v5/plumbing/object"
2311- gssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
2312+ "github.com/charmbracelet/ssh"
2313 "github.com/matryer/is"
2314- cssh "golang.org/x/crypto/ssh"
2315+ gossh "golang.org/x/crypto/ssh"
2316 )
2317
2318-var (
2319- testdata = "testdata"
2320- cfg = &config.Config{
2321- BindAddr: "",
2322- Host: "localhost",
2323- Port: 22222,
2324- RepoPath: fmt.Sprintf("%s/repos", testdata),
2325- KeyPath: fmt.Sprintf("%s/key", testdata),
2326- }
2327- pkPath = ""
2328-)
2329-
2330-func TestServer(t *testing.T) {
2331- t.Cleanup(func() {
2332- os.RemoveAll(testdata)
2333- })
2334- is := is.New(t)
2335- _, pkPath = createKeyPair(t)
2336- s := setupServer(t)
2337- err := s.Reload()
2338- is.NoErr(err)
2339- t.Run("TestPushRepo", testPushRepo)
2340- t.Run("TestCloneRepo", testCloneRepo)
2341-}
2342-
2343-func testPushRepo(t *testing.T) {
2344- is := is.New(t)
2345- rp := t.TempDir()
2346- r, err := git.PlainInit(rp, false)
2347- is.NoErr(err)
2348- wt, err := r.Worktree()
2349- is.NoErr(err)
2350- _, err = wt.Filesystem.Create("testfile")
2351- is.NoErr(err)
2352- _, err = wt.Add("testfile")
2353- is.NoErr(err)
2354- author := &object.Signature{
2355- Name: "test",
2356- Email: "",
2357- }
2358- _, err = wt.Commit("test commit", &git.CommitOptions{
2359- All: true,
2360- Author: author,
2361- Committer: author,
2362- })
2363- is.NoErr(err)
2364- _, err = r.CreateRemote(&gconfig.RemoteConfig{
2365- Name: "origin",
2366- URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.Port, "testrepo")},
2367- })
2368- auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
2369- is.NoErr(err)
2370- auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
2371- HostKeyCallback: cssh.InsecureIgnoreHostKey(),
2372- }
2373- err = r.Push(&git.PushOptions{
2374- RemoteName: "origin",
2375- Auth: auth,
2376- })
2377- is.NoErr(err)
2378+func randomPort() int {
2379+ addr, _ := net.Listen("tcp", ":0") //nolint:gosec
2380+ _ = addr.Close()
2381+ return addr.Addr().(*net.TCPAddr).Port
2382 }
2383
2384-func testCloneRepo(t *testing.T) {
2385- is := is.New(t)
2386- auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
2387- is.NoErr(err)
2388- auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
2389- HostKeyCallback: cssh.InsecureIgnoreHostKey(),
2390+func setupServer(tb testing.TB) (*Server, *config.Config, string) {
2391+ tb.Helper()
2392+ tb.Log("creating keypair")
2393+ pub, pkPath := createKeyPair(tb)
2394diff --git a/server/session.go b/server/session.go
2395index a645c09c97948ba19ddcd231e427b241f2b44875..56bc02ed88ef87d9aa1474dcc99435627e0f4b20 100644
2396--- a/server/session.go
2397+++ b/server/session.go
2398@@ -5,21 +5,18 @@ import (
2399
2400 "github.com/aymanbagabas/go-osc52"
2401 tea "github.com/charmbracelet/bubbletea"
2402- appCfg "github.com/charmbracelet/soft-serve/config"
2403+ "github.com/charmbracelet/soft-serve/server/backend"
2404 cm "github.com/charmbracelet/soft-serve/server/cmd"
2405+ "github.com/charmbracelet/soft-serve/server/config"
2406 "github.com/charmbracelet/soft-serve/ui"
2407 "github.com/charmbracelet/soft-serve/ui/common"
2408- "github.com/charmbracelet/soft-serve/ui/keymap"
2409- "github.com/charmbracelet/soft-serve/ui/styles"
2410+ "github.com/charmbracelet/ssh"
2411 "github.com/charmbracelet/wish"
2412 bm "github.com/charmbracelet/wish/bubbletea"
2413- gm "github.com/charmbracelet/wish/git"
2414- "github.com/gliderlabs/ssh"
2415- zone "github.com/lrstanley/bubblezone"
2416 )
2417
2418 // SessionHandler is the soft-serve bubbletea ssh session handler.
2419-func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
2420+func SessionHandler(cfg *config.Config) bm.ProgramHandler {
2421 return func(s ssh.Session) *tea.Program {
2422 pty, _, active := s.Pty()
2423 if !active {
2424@@ -29,32 +26,18 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
2425 initialRepo := ""
2426 if len(cmd) == 1 {
2427 initialRepo = cmd[0]
2428- auth := ac.AuthRepo(initialRepo, s.PublicKey())
2429- if auth < gm.ReadOnlyAccess {
2430+ auth := cfg.Access.AccessLevel(initialRepo, s.PublicKey())
2431+ if auth < backend.ReadOnlyAccess {
2432 wish.Fatalln(s, cm.ErrUnauthorized)
2433 return nil
2434 }
2435 }
2436- if ac.Cfg.Callbacks != nil {
2437- ac.Cfg.Callbacks.Tui("new session")
2438- }
2439 envs := s.Environ()
2440 envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
2441 output := osc52.NewOutput(s, envs)
2442- c := common.Common{
2443- Copy: output,
2444- Styles: styles.DefaultStyles(),
2445- KeyMap: keymap.DefaultKeyMap(),
2446- Width: pty.Window.Width,
2447- Height: pty.Window.Height,
2448- Zone: zone.New(),
2449- }
2450- m := ui.New(
2451- ac,
2452- s,
2453- c,
2454- initialRepo,
2455- )
2456+ c := common.NewCommon(s.Context(), output, pty.Window.Width, pty.Window.Height)
2457+ c.SetValue(common.ConfigKey, cfg)
2458+ m := ui.New(c, initialRepo)
2459 p := tea.NewProgram(m,
2460 tea.WithInput(s),
2461 tea.WithOutput(s),
2462diff --git a/server/session_test.go b/server/session_test.go
2463index 324402e653ec885ed2aca08ee475c54c463fed19..bd81a2320ba8fa14c2f5712b4154a8e6aa98ed76 100644
2464--- a/server/session_test.go
2465+++ b/server/session_test.go
2466@@ -1,19 +1,16 @@
2467 package server
2468
2469 import (
2470- "bytes"
2471 "errors"
2472+ "fmt"
2473 "os"
2474- "strings"
2475 "testing"
2476 "time"
2477
2478- appCfg "github.com/charmbracelet/soft-serve/config"
2479- cm "github.com/charmbracelet/soft-serve/server/cmd"
2480 "github.com/charmbracelet/soft-serve/server/config"
2481+ "github.com/charmbracelet/ssh"
2482 bm "github.com/charmbracelet/wish/bubbletea"
2483 "github.com/charmbracelet/wish/testsession"
2484- "github.com/gliderlabs/ssh"
2485 "github.com/matryer/is"
2486 "github.com/muesli/termenv"
2487 gossh "golang.org/x/crypto/ssh"
2488@@ -21,53 +18,40 @@ import (
2489
2490 func TestSession(t *testing.T) {
2491 is := is.New(t)
2492- t.Run("unauthorized repo access", func(t *testing.T) {
2493- var out bytes.Buffer
2494- s := setup(t)
2495- s.Stderr = &out
2496- defer s.Close()
2497- err := s.RequestPty("xterm", 80, 40, nil)
2498- is.NoErr(err)
2499- err = s.Run("config")
2500- // Session writes error and exits
2501- is.True(strings.Contains(out.String(), cm.ErrUnauthorized.Error()))
2502- var ee *gossh.ExitError
2503- is.True(errors.As(err, &ee) && ee.ExitStatus() == 1)
2504- })
2505 t.Run("authorized repo access", func(t *testing.T) {
2506 s := setup(t)
2507 s.Stderr = os.Stderr
2508 defer s.Close()
2509 err := s.RequestPty("xterm", 80, 40, nil)
2510 is.NoErr(err)
2511- in, err := s.StdinPipe()
2512- is.NoErr(err)
2513 go func() {
2514- <-time.After(time.Second)
2515- // Send "q" to exit the config command
2516- in.Write([]byte("q"))
2517+ time.Sleep(1 * time.Second)
2518+ s.Signal(gossh.SIGTERM)
2519+ // FIXME: exit with code 0 instead of forcibly closing the session
2520+ s.Close()
2521 }()
2522- err = s.Shell()
2523- is.NoErr(err)
2524+ err = s.Run("test")
2525+ var ee *gossh.ExitMissingError
2526+ is.True(errors.As(err, &ee))
2527 })
2528 }
2529
2530 func setup(tb testing.TB) *gossh.Session {
2531- is := is.New(tb)
2532 tb.Helper()
2533- cfg.RepoPath = tb.TempDir()
2534- ac, err := appCfg.NewConfig(&config.Config{
2535- Port: 22226,
2536- KeyPath: tb.TempDir(),
2537- RepoPath: tb.TempDir(),
2538- InitialAdminKeys: []string{
2539- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH",
2540- },
2541+ is := is.New(tb)
2542+ dp := tb.TempDir()
2543+ is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp))
2544+ is.NoErr(os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", ":9418"))
2545+ is.NoErr(os.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())))
2546+ tb.Cleanup(func() {
2547+ is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH"))
2548+ is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR"))
2549+ is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR"))
2550+ is.NoErr(os.RemoveAll(dp))
2551 })
2552- ac.AnonAccess = "read-only"
2553- is.NoErr(err)
2554+ cfg := config.DefaultConfig()
2555 return testsession.New(tb, &ssh.Server{
2556- Handler: bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256)(func(s ssh.Session) {
2557+ Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) {
2558 _, _, active := s.Pty()
2559 tb.Logf("PTY active %v", active)
2560 tb.Log(s.Command())
2561diff --git a/server/ssh.go b/server/ssh.go
2562new file mode 100644
2563index 0000000000000000000000000000000000000000..1c73928255e1ef19c9f9b70b87438669d5ef4a20
2564--- /dev/null
2565+++ b/server/ssh.go
2566@@ -0,0 +1,157 @@
2567+package server
2568+
2569+import (
2570+ "errors"
2571+ "fmt"
2572+ "path/filepath"
2573+ "strings"
2574+ "time"
2575+
2576+ "github.com/charmbracelet/log"
2577+ "github.com/charmbracelet/soft-serve/server/backend"
2578+ cm "github.com/charmbracelet/soft-serve/server/cmd"
2579+ "github.com/charmbracelet/soft-serve/server/config"
2580+ "github.com/charmbracelet/ssh"
2581+ "github.com/charmbracelet/wish"
2582+ bm "github.com/charmbracelet/wish/bubbletea"
2583+ lm "github.com/charmbracelet/wish/logging"
2584+ rm "github.com/charmbracelet/wish/recover"
2585+ "github.com/muesli/termenv"
2586+ gossh "golang.org/x/crypto/ssh"
2587+)
2588+
2589+// SSHServer is a SSH server that implements the git protocol.
2590+type SSHServer struct {
2591+ *ssh.Server
2592+ cfg *config.Config
2593+}
2594+
2595+// NewSSHServer returns a new SSHServer.
2596+func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
2597+ var err error
2598+ s := &SSHServer{cfg: cfg}
2599+ logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
2600+ mw := []wish.Middleware{
2601+ rm.MiddlewareWithLogger(
2602+ logger,
2603+ // BubbleTea middleware.
2604+ bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
2605+ // Command middleware must come after the git middleware.
2606+ cm.Middleware(cfg),
2607+ // Git middleware.
2608+ s.Middleware(cfg),
2609+ lm.MiddlewareWithLogger(logger),
2610+ ),
2611+ }
2612+ s.Server, err = wish.NewServer(
2613+ ssh.PublicKeyAuth(s.PublicKeyHandler),
2614+ ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
2615+ wish.WithAddress(cfg.SSH.ListenAddr),
2616+ wish.WithHostKeyPath(cfg.SSH.KeyPath),
2617+ wish.WithMiddleware(mw...),
2618+ )
2619+ if err != nil {
2620+ return nil, err
2621+ }
2622+
2623+ if cfg.SSH.MaxTimeout > 0 {
2624+ s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
2625+ }
2626+ if cfg.SSH.IdleTimeout > 0 {
2627+ s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
2628+ }
2629+
2630+ return s, nil
2631+}
2632+
2633+// PublicKeyAuthHandler handles public key authentication.
2634+func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
2635+ al := s.cfg.Access.AccessLevel("", pk)
2636+ logger.Debug("publickey handler", "level", al)
2637+ return al > backend.NoAccess
2638+}
2639+
2640+// KeyboardInteractiveHandler handles keyboard interactive authentication.
2641+func (s *SSHServer) KeyboardInteractiveHandler(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
2642+ return true
2643+}
2644+
2645+// Middleware adds Git server functionality to the ssh.Server. Repos are stored
2646+// in the specified repo directory. The provided Hooks implementation will be
2647+// checked for access on a per repo basis for a ssh.Session public key.
2648+// Hooks.Push and Hooks.Fetch will be called on successful completion of
2649+// their commands.
2650+func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
2651+ return func(sh ssh.Handler) ssh.Handler {
2652+ return func(s ssh.Session) {
2653+ func() {
2654+ cmd := s.Command()
2655+ if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
2656+ gc := cmd[0]
2657+ // repo should be in the form of "repo.git"
2658+ repo := sanitizeRepoName(cmd[1])
2659+ name := repo
2660+ if strings.Contains(repo, "/") {
2661+ log.Printf("invalid repo: %s", repo)
2662+ sshFatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
2663+ return
2664+ }
2665+ pk := s.PublicKey()
2666diff --git a/ui/common/common.go b/ui/common/common.go
2667index 8a5cb451a92824ba6289b438c0b03919bfbeba0a..660b6fdabba2f703fb0a22ea2dfcd13fd85528b7 100644
2668--- a/ui/common/common.go
2669+++ b/ui/common/common.go
2670@@ -1,20 +1,56 @@
2671 package common
2672
2673 import (
2674+ "context"
2675+
2676 "github.com/aymanbagabas/go-osc52"
2677+ "github.com/charmbracelet/soft-serve/git"
2678+ "github.com/charmbracelet/soft-serve/server/config"
2679 "github.com/charmbracelet/soft-serve/ui/keymap"
2680 "github.com/charmbracelet/soft-serve/ui/styles"
2681+ "github.com/charmbracelet/ssh"
2682 zone "github.com/lrstanley/bubblezone"
2683 )
2684
2685+type contextKey struct {
2686+ name string
2687+}
2688+
2689+// Keys to use for context.Context.
2690+var (
2691+ ConfigKey = &contextKey{"config"}
2692+ RepoKey = &contextKey{"repo"}
2693+)
2694+
2695 // Common is a struct all components should embed.
2696 type Common struct {
2697- Copy *osc52.Output
2698- Styles *styles.Styles
2699- KeyMap *keymap.KeyMap
2700- Width int
2701- Height int
2702- Zone *zone.Manager
2703+ ctx context.Context
2704+ Width, Height int
2705+ Styles *styles.Styles
2706+ KeyMap *keymap.KeyMap
2707+ Copy *osc52.Output
2708+ Zone *zone.Manager
2709+}
2710+
2711+// NewCommon returns a new Common struct.
2712+func NewCommon(ctx context.Context, copy *osc52.Output, width, height int) Common {
2713+ if ctx == nil {
2714+ ctx = context.TODO()
2715+ }
2716+ return Common{
2717+ ctx: ctx,
2718+ Width: width,
2719+ Height: height,
2720+ Copy: copy,
2721+ Styles: styles.DefaultStyles(),
2722+ KeyMap: keymap.DefaultKeyMap(),
2723+ Zone: zone.New(),
2724+ }
2725+}
2726+
2727+// SetValue sets a value in the context.
2728+func (c *Common) SetValue(key, value interface{}) {
2729+ c.ctx = context.WithValue(c.ctx, key, value)
2730 }
2731
2732 // SetSize sets the width and height of the common struct.
2733@@ -22,3 +58,30 @@ func (c *Common) SetSize(width, height int) {
2734 c.Width = width
2735 c.Height = height
2736 }
2737+
2738+// Config returns the server config.
2739+func (c *Common) Config() *config.Config {
2740+ v := c.ctx.Value(ConfigKey)
2741+ if cfg, ok := v.(*config.Config); ok {
2742+ return cfg
2743+ }
2744+ return nil
2745+}
2746+
2747+// Repo returns the repository.
2748+func (c *Common) Repo() *git.Repository {
2749+ v := c.ctx.Value(RepoKey)
2750+ if r, ok := v.(*git.Repository); ok {
2751+ return r
2752+ }
2753+ return nil
2754+}
2755+
2756+// PublicKey returns the public key.
2757+func (c *Common) PublicKey() ssh.PublicKey {
2758+ v := c.ctx.Value(ssh.ContextKeyPublicKey)
2759+ if p, ok := v.(ssh.PublicKey); ok {
2760+ return p
2761+ }
2762+ return nil
2763+}
2764diff --git a/ui/common/error.go b/ui/common/error.go
2765index fe972980562270ccfb0c8f854e3a3360b4389bc7..753f5f26f77fc778157c2dc5d3c7b06440a29b8e 100644
2766--- a/ui/common/error.go
2767+++ b/ui/common/error.go
2768@@ -1,6 +1,13 @@
2769 package common
2770
2771-import tea "github.com/charmbracelet/bubbletea"
2772+import (
2773+ "errors"
2774+
2775+ tea "github.com/charmbracelet/bubbletea"
2776+)
2777+
2778+// ErrMissingRepo indicates that the requested repository could not be found.
2779+var ErrMissingRepo = errors.New("missing repo")
2780
2781 // ErrorMsg is a Bubble Tea message that represents an error.
2782 type ErrorMsg error
2783diff --git a/ui/common/utils.go b/ui/common/utils.go
2784index 7c817a50ebcb6b92b04006837bde1bbd3ae732b0..de8fbe8a6316795c5f581fae471f3287122d1894 100644
2785--- a/ui/common/utils.go
2786+++ b/ui/common/utils.go
2787@@ -1,6 +1,10 @@
2788 package common
2789
2790-import "github.com/muesli/reflow/truncate"
2791+import (
2792+ "fmt"
2793+
2794+ "github.com/muesli/reflow/truncate"
2795+)
2796
2797 // TruncateString is a convenient wrapper around truncate.TruncateString.
2798 func TruncateString(s string, max int) string {
2799@@ -9,3 +13,12 @@ func TruncateString(s string, max int) string {
2800 }
2801 return truncate.StringWithTail(s, uint(max), "…")
2802 }
2803+
2804+// RepoURL returns the URL of the repository.
2805+func RepoURL(host string, port string, name string) string {
2806+ p := ""
2807+ if port != "22" {
2808+ p += ":" + port
2809+ }
2810+ return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
2811+}
2812diff --git a/ui/components/code/code.go b/ui/components/code/code.go
2813index 9c832bc836b7f1437a7488b86ca6f55d95af241a..8b442db8209023b34cc2167f43477ef0a16a2286 100644
2814--- a/ui/components/code/code.go
2815+++ b/ui/components/code/code.go
2816@@ -47,7 +47,7 @@ func New(c common.Common, content, extension string) *Code {
2817 content: content,
2818 extension: extension,
2819 Viewport: vp.New(c),
2820- NoContentStyle: c.Styles.CodeNoContent.Copy(),
2821+ NoContentStyle: c.Styles.NoContent.Copy(),
2822 LineDigitStyle: lineDigitStyle,
2823 LineBarStyle: lineBarStyle,
2824 }
2825diff --git a/ui/git.go b/ui/git.go
2826deleted file mode 100644
2827index e4dcf80a2453d0f946ae09ad5dbd7ba71eb53571..0000000000000000000000000000000000000000
2828--- a/ui/git.go
2829+++ /dev/null
2830@@ -1,25 +0,0 @@
2831-package ui
2832-
2833-import (
2834- "github.com/charmbracelet/soft-serve/config"
2835- "github.com/charmbracelet/soft-serve/ui/git"
2836-)
2837-
2838-// source is a wrapper around config.RepoSource that implements git.GitRepoSource.
2839-type source struct {
2840- *config.RepoSource
2841-}
2842-
2843-// GetRepo implements git.GitRepoSource.
2844-func (s *source) GetRepo(name string) (git.GitRepo, error) {
2845- return s.RepoSource.GetRepo(name)
2846-}
2847-
2848-// AllRepos implements git.GitRepoSource.
2849-func (s *source) AllRepos() []git.GitRepo {
2850- rs := make([]git.GitRepo, 0)
2851- for _, r := range s.RepoSource.AllRepos() {
2852- rs = append(rs, r)
2853- }
2854- return rs
2855-}
2856diff --git a/ui/git/git.go b/ui/git/git.go
2857deleted file mode 100644
2858index c51fee1cddf57cf602985154c117385552a1d752..0000000000000000000000000000000000000000
2859--- a/ui/git/git.go
2860+++ /dev/null
2861@@ -1,42 +0,0 @@
2862-package git
2863-
2864-import (
2865- "errors"
2866- "fmt"
2867-
2868- "github.com/charmbracelet/soft-serve/git"
2869-)
2870-
2871-// ErrMissingRepo indicates that the requested repository could not be found.
2872-var ErrMissingRepo = errors.New("missing repo")
2873-
2874-// GitRepo is an interface for Git repositories.
2875-type GitRepo interface {
2876- Repo() string
2877- Name() string
2878- Description() string
2879- Readme() (string, string)
2880- HEAD() (*git.Reference, error)
2881- Commit(string) (*git.Commit, error)
2882- CommitsByPage(*git.Reference, int, int) (git.Commits, error)
2883- CountCommits(*git.Reference) (int64, error)
2884- Diff(*git.Commit) (*git.Diff, error)
2885- References() ([]*git.Reference, error)
2886- Tree(*git.Reference, string) (*git.Tree, error)
2887- IsPrivate() bool
2888-}
2889-
2890-// GitRepoSource is an interface for Git repository factory.
2891-type GitRepoSource interface {
2892- GetRepo(string) (GitRepo, error)
2893- AllRepos() []GitRepo
2894-}
2895-
2896-// RepoURL returns the URL of the repository.
2897-func RepoURL(host string, port int, name string) string {
2898- p := ""
2899- if port != 22 {
2900- p += fmt.Sprintf(":%d", port)
2901- }
2902- return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
2903-}
2904diff --git a/ui/pages/repo/empty.go b/ui/pages/repo/empty.go
2905new file mode 100644
2906index 0000000000000000000000000000000000000000..bddab29f15fc476c9f7b974ecc877c6e294dcacc
2907--- /dev/null
2908+++ b/ui/pages/repo/empty.go
2909@@ -0,0 +1,45 @@
2910+package repo
2911+
2912+import (
2913+ "fmt"
2914+ "strings"
2915+
2916+ "github.com/charmbracelet/soft-serve/server/config"
2917+)
2918+
2919+func defaultEmptyRepoMsg(cfg *config.Config, repo string) string {
2920+ host := cfg.Backend.ServerHost()
2921+ if cfg.Backend.ServerPort() != "22" {
2922+ host = fmt.Sprintf("%s:%s", host, cfg.Backend.ServerPort())
2923+ }
2924+ repo = strings.TrimSuffix(repo, ".git")
2925+ return fmt.Sprintf(`# Quick Start
2926+
2927+Get started by cloning this repository, add your files, commit, and push.
2928+
2929+## Clone this repository.
2930+
2931+`+"```"+`sh
2932+git clone ssh://%[1]s/%[2]s.git
2933+`+"```"+`
2934+
2935+## Creating a new repository on the command line
2936+
2937+`+"```"+`sh
2938+touch README.md
2939+git init
2940+git add README.md
2941+git branch -M main
2942+git commit -m "first commit"
2943+git remote add origin ssh://%[1]s/%[2]s.git
2944+git push -u origin main
2945+`+"```"+`
2946+
2947+## Pushing an existing repository from the command line
2948+
2949+`+"```"+`sh
2950+git remote add origin ssh://%[1]s/%[2]s.git
2951+git push -u origin main
2952+`+"```"+`
2953+`, host, repo)
2954+}
2955diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go
2956index 745016102a4c169f60381927f28f036b59167041..83f01d6ddf4063bba7a27f31b0c7fcff68ed17a9 100644
2957--- a/ui/pages/repo/files.go
2958+++ b/ui/pages/repo/files.go
2959@@ -3,16 +3,17 @@ package repo
2960 import (
2961 "errors"
2962 "fmt"
2963+ "log"
2964 "path/filepath"
2965
2966 "github.com/alecthomas/chroma/lexers"
2967 "github.com/charmbracelet/bubbles/key"
2968 tea "github.com/charmbracelet/bubbletea"
2969- ggit "github.com/charmbracelet/soft-serve/git"
2970+ "github.com/charmbracelet/soft-serve/git"
2971+ "github.com/charmbracelet/soft-serve/server/backend"
2972 "github.com/charmbracelet/soft-serve/ui/common"
2973 "github.com/charmbracelet/soft-serve/ui/components/code"
2974 "github.com/charmbracelet/soft-serve/ui/components/selector"
2975- "github.com/charmbracelet/soft-serve/ui/git"
2976 )
2977
2978 type filesView int
2979@@ -49,9 +50,9 @@ type FileContentMsg struct {
2980 type Files struct {
2981 common common.Common
2982 selector *selector.Selector
2983- ref *ggit.Reference
2984+ ref *git.Reference
2985 activeView filesView
2986- repo git.GitRepo
2987+ repo backend.Repository
2988 code *code.Code
2989 path string
2990 currentItem *FileItem
2991@@ -200,8 +201,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2992 cmds := make([]tea.Cmd, 0)
2993 switch msg := msg.(type) {
2994 case RepoMsg:
2995- f.repo = git.GitRepo(msg)
2996- cmds = append(cmds, f.Init())
2997+ f.repo = msg
2998 case RefMsg:
2999 f.ref = msg
3000 cmds = append(cmds, f.Init())
3001@@ -265,6 +265,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3002 }
3003 }
3004 }
3005+ case EmptyRepoMsg:
3006+ f.ref = nil
3007+ f.path = ""
3008+ f.currentItem = nil
3009+ f.activeView = filesViewFiles
3010+ f.lastSelected = make([]int, 0)
3011+ f.selector.Select(0)
3012+ cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
3013 }
3014 switch f.activeView {
3015 case filesViewFiles:
3016@@ -320,14 +328,21 @@ func (f *Files) updateFilesCmd() tea.Msg {
3017 files := make([]selector.IdentifiableItem, 0)
3018 dirs := make([]selector.IdentifiableItem, 0)
3019 if f.ref == nil {
3020+ log.Printf("ui: files: ref is nil")
3021 return common.ErrorMsg(errNoRef)
3022 }
3023- t, err := f.repo.Tree(f.ref, f.path)
3024+ r, err := f.repo.Repository()
3025+ if err != nil {
3026+ return common.ErrorMsg(err)
3027+ }
3028+ t, err := r.TreePath(f.ref, f.path)
3029 if err != nil {
3030+ log.Printf("ui: files: error getting tree %v", err)
3031 return common.ErrorMsg(err)
3032 }
3033 ents, err := t.Entries()
3034 if err != nil {
3035+ log.Printf("ui: files: error listing files %v", err)
3036 return common.ErrorMsg(err)
3037 }
3038 ents.Sort()
3039@@ -347,6 +362,7 @@ func (f *Files) selectTreeCmd() tea.Msg {
3040 f.selector.Select(0)
3041 return f.updateFilesCmd()
3042 }
3043+ log.Printf("ui: files: current item is not a tree")
3044 return common.ErrorMsg(errNoFileSelected)
3045 }
3046
3047@@ -355,25 +371,30 @@ func (f *Files) selectFileCmd() tea.Msg {
3048 if i != nil && !i.entry.IsTree() {
3049 fi := i.entry.File()
3050 if i.Mode().IsDir() || f == nil {
3051+ log.Printf("ui: files: current item is not a file")
3052 return common.ErrorMsg(errInvalidFile)
3053 }
3054 bin, err := fi.IsBinary()
3055 if err != nil {
3056 f.path = filepath.Dir(f.path)
3057+ log.Printf("ui: files: error checking if file is binary %v", err)
3058 return common.ErrorMsg(err)
3059diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go
3060index a1511a5ff37db765010d83757d5ac92808d4c164..23bc2388060ccc66752d155773415ff0a8a4e3ff 100644
3061--- a/ui/pages/repo/log.go
3062+++ b/ui/pages/repo/log.go
3063@@ -2,6 +2,7 @@ package repo
3064
3065 import (
3066 "fmt"
3067+ "log"
3068 "strings"
3069 "time"
3070
3071@@ -10,12 +11,12 @@ import (
3072 tea "github.com/charmbracelet/bubbletea"
3073 gansi "github.com/charmbracelet/glamour/ansi"
3074 "github.com/charmbracelet/lipgloss"
3075- ggit "github.com/charmbracelet/soft-serve/git"
3076+ "github.com/charmbracelet/soft-serve/git"
3077+ "github.com/charmbracelet/soft-serve/server/backend"
3078 "github.com/charmbracelet/soft-serve/ui/common"
3079 "github.com/charmbracelet/soft-serve/ui/components/footer"
3080 "github.com/charmbracelet/soft-serve/ui/components/selector"
3081 "github.com/charmbracelet/soft-serve/ui/components/viewport"
3082- "github.com/charmbracelet/soft-serve/ui/git"
3083 "github.com/muesli/reflow/wrap"
3084 "github.com/muesli/termenv"
3085 )
3086@@ -36,10 +37,10 @@ type LogCountMsg int64
3087 type LogItemsMsg []selector.IdentifiableItem
3088
3089 // LogCommitMsg is a message that contains a git commit.
3090-type LogCommitMsg *ggit.Commit
3091+type LogCommitMsg *git.Commit
3092
3093 // LogDiffMsg is a message that contains a git diff.
3094-type LogDiffMsg *ggit.Diff
3095+type LogDiffMsg *git.Diff
3096
3097 // Log is a model that displays a list of commits and their diffs.
3098 type Log struct {
3099@@ -47,13 +48,13 @@ type Log struct {
3100 selector *selector.Selector
3101 vp *viewport.Viewport
3102 activeView logView
3103- repo git.GitRepo
3104- ref *ggit.Reference
3105+ repo backend.Repository
3106+ ref *git.Reference
3107 count int64
3108 nextPage int
3109- activeCommit *ggit.Commit
3110- selectedCommit *ggit.Commit
3111- currentDiff *ggit.Diff
3112+ activeCommit *git.Commit
3113+ selectedCommit *git.Commit
3114+ currentDiff *git.Diff
3115 loadingTime time.Time
3116 loading bool
3117 spinner spinner.Model
3118@@ -77,9 +78,8 @@ func NewLog(common common.Common) *Log {
3119 selector.KeyMap.NextPage = common.KeyMap.NextPage
3120 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
3121 l.selector = selector
3122- s := spinner.New()
3123- s.Spinner = spinner.Dot
3124- s.Style = common.Styles.Spinner
3125+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
3126+ spinner.WithStyle(common.Styles.Spinner))
3127 l.spinner = s
3128 return l
3129 }
3130@@ -189,8 +189,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3131 cmds := make([]tea.Cmd, 0)
3132 switch msg := msg.(type) {
3133 case RepoMsg:
3134- l.repo = git.GitRepo(msg)
3135- cmds = append(cmds, l.Init())
3136+ l.repo = msg
3137 case RefMsg:
3138 l.ref = msg
3139 cmds = append(cmds, l.Init())
3140@@ -245,6 +244,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3141 if l.activeView == logViewDiff {
3142 l.activeView = logViewCommits
3143 l.selectedCommit = nil
3144+ cmds = append(cmds, updateStatusBarCmd)
3145 }
3146 case selector.ActiveMsg:
3147 switch sel := msg.IdentifiableItem.(type) {
3148@@ -299,6 +299,16 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3149 l.startLoading(),
3150 )
3151 }
3152+ case EmptyRepoMsg:
3153+ l.ref = nil
3154+ l.loading = false
3155+ l.activeView = logViewCommits
3156+ l.nextPage = 0
3157+ l.count = 0
3158+ l.activeCommit = nil
3159+ l.selectedCommit = nil
3160+ l.selector.Select(0)
3161+ cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
3162 }
3163diff --git a/ui/pages/repo/readme.go b/ui/pages/repo/readme.go
3164index 8605d320545e323fae40e2a7c0d6dd3761a03732..5374779b9a1e3560b06eaf46fdb8c357dbc851b3 100644
3165--- a/ui/pages/repo/readme.go
3166+++ b/ui/pages/repo/readme.go
3167@@ -2,22 +2,27 @@ package repo
3168
3169 import (
3170 "fmt"
3171+ "path/filepath"
3172
3173 "github.com/charmbracelet/bubbles/key"
3174 tea "github.com/charmbracelet/bubbletea"
3175+ "github.com/charmbracelet/soft-serve/server/backend"
3176 "github.com/charmbracelet/soft-serve/ui/common"
3177 "github.com/charmbracelet/soft-serve/ui/components/code"
3178- "github.com/charmbracelet/soft-serve/ui/git"
3179 )
3180
3181-type ReadmeMsg struct{}
3182+// ReadmeMsg is a message sent when the readme is loaded.
3183+type ReadmeMsg struct {
3184+ Msg tea.Msg
3185+}
3186
3187 // Readme is the readme component page.
3188 type Readme struct {
3189- common common.Common
3190- code *code.Code
3191- ref RefMsg
3192- repo git.GitRepo
3193+ common common.Common
3194+ code *code.Code
3195+ ref RefMsg
3196+ repo backend.Repository
3197+ readmePath string
3198 }
3199
3200 // NewReadme creates a new readme model.
3201@@ -64,15 +69,7 @@ func (r *Readme) FullHelp() [][]key.Binding {
3202
3203 // Init implements tea.Model.
3204 func (r *Readme) Init() tea.Cmd {
3205- if r.repo == nil {
3206- return common.ErrorCmd(git.ErrMissingRepo)
3207- }
3208- rm, rp := r.repo.Readme()
3209- r.code.GotoTop()
3210- return tea.Batch(
3211- r.code.SetContent(rm, rp),
3212- r.updateReadmeCmd,
3213- )
3214+ return r.updateReadmeCmd
3215 }
3216
3217 // Update implements tea.Model.
3218@@ -80,11 +77,13 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3219 cmds := make([]tea.Cmd, 0)
3220 switch msg := msg.(type) {
3221 case RepoMsg:
3222- r.repo = git.GitRepo(msg)
3223- cmds = append(cmds, r.Init())
3224+ r.repo = msg
3225 case RefMsg:
3226 r.ref = msg
3227 cmds = append(cmds, r.Init())
3228+ case EmptyRepoMsg:
3229+ r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
3230+ r.repo.Name()), ".md")
3231 }
3232 c, cmd := r.code.Update(msg)
3233 r.code = c.(*code.Code)
3234@@ -101,7 +100,11 @@ func (r *Readme) View() string {
3235
3236 // StatusBarValue implements statusbar.StatusBar.
3237 func (r *Readme) StatusBarValue() string {
3238- return ""
3239+ dir := filepath.Dir(r.readmePath)
3240+ if dir == "." {
3241+ return ""
3242+ }
3243+ return dir
3244 }
3245
3246 // StatusBarInfo implements statusbar.StatusBar.
3247@@ -110,5 +113,19 @@ func (r *Readme) StatusBarInfo() string {
3248 }
3249
3250 func (r *Readme) updateReadmeCmd() tea.Msg {
3251- return ReadmeMsg{}
3252+ m := ReadmeMsg{}
3253+ if r.repo == nil {
3254+ return common.ErrorCmd(common.ErrMissingRepo)
3255+ }
3256+ rm, rp, err := backend.Readme(r.repo)
3257+ if err != nil {
3258+ return common.ErrorCmd(err)
3259+ }
3260+ r.readmePath = rp
3261+ r.code.GotoTop()
3262+ cmd := r.code.SetContent(rm, rp)
3263+ if cmd != nil {
3264+ m.Msg = cmd()
3265+ }
3266+ return m
3267diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go
3268index 308a26288372c3ecbcaa4b9b985e51075fb4136f..b0af0defeb94355d3f9314fb65cc4fba2e53c9a6 100644
3269--- a/ui/pages/repo/refs.go
3270+++ b/ui/pages/repo/refs.go
3271@@ -3,22 +3,27 @@ package repo
3272 import (
3273 "errors"
3274 "fmt"
3275+ "log"
3276 "sort"
3277 "strings"
3278
3279 "github.com/charmbracelet/bubbles/key"
3280 tea "github.com/charmbracelet/bubbletea"
3281+ "github.com/charmbracelet/soft-serve/git"
3282 ggit "github.com/charmbracelet/soft-serve/git"
3283+ "github.com/charmbracelet/soft-serve/server/backend"
3284 "github.com/charmbracelet/soft-serve/ui/common"
3285 "github.com/charmbracelet/soft-serve/ui/components/selector"
3286 "github.com/charmbracelet/soft-serve/ui/components/tabs"
3287- "github.com/charmbracelet/soft-serve/ui/git"
3288 )
3289
3290 var (
3291 errNoRef = errors.New("no reference specified")
3292 )
3293
3294+// RefMsg is a message that contains a git.Reference.
3295+type RefMsg *ggit.Reference
3296+
3297 // RefItemsMsg is a message that contains a list of RefItem.
3298 type RefItemsMsg struct {
3299 prefix string
3300@@ -29,9 +34,9 @@ type RefItemsMsg struct {
3301 type Refs struct {
3302 common common.Common
3303 selector *selector.Selector
3304- repo git.GitRepo
3305- ref *ggit.Reference
3306- activeRef *ggit.Reference
3307+ repo backend.Repository
3308+ ref *git.Reference
3309+ activeRef *git.Reference
3310 refPrefix string
3311 }
3312
3313@@ -104,8 +109,7 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3314 switch msg := msg.(type) {
3315 case RepoMsg:
3316 r.selector.Select(0)
3317- r.repo = git.GitRepo(msg)
3318- cmds = append(cmds, r.Init())
3319+ r.repo = msg
3320 case RefMsg:
3321 r.ref = msg
3322 cmds = append(cmds, r.Init())
3323@@ -136,6 +140,9 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3324 case key.Matches(msg, r.common.KeyMap.SelectItem):
3325 cmds = append(cmds, r.selector.SelectItem)
3326 }
3327+ case EmptyRepoMsg:
3328+ r.ref = nil
3329+ cmds = append(cmds, r.setItems([]selector.IdentifiableItem{}))
3330 }
3331 m, cmd := r.selector.Update(msg)
3332 r.selector = m.(*selector.Selector)
3333@@ -169,8 +176,13 @@ func (r *Refs) StatusBarInfo() string {
3334
3335 func (r *Refs) updateItemsCmd() tea.Msg {
3336 its := make(RefItems, 0)
3337- refs, err := r.repo.References()
3338+ rr, err := r.repo.Repository()
3339+ if err != nil {
3340+ return common.ErrorMsg(err)
3341+ }
3342+ refs, err := rr.References()
3343 if err != nil {
3344+ log.Printf("ui: error getting references: %v", err)
3345 return common.ErrorMsg(err)
3346 }
3347 for _, ref := range refs {
3348@@ -189,8 +201,37 @@ func (r *Refs) updateItemsCmd() tea.Msg {
3349 }
3350 }
3351
3352+func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd {
3353+ return func() tea.Msg {
3354+ return RefItemsMsg{
3355+ items: items,
3356+ prefix: r.refPrefix,
3357+ }
3358+ }
3359+}
3360+
3361 func switchRefCmd(ref *ggit.Reference) tea.Cmd {
3362 return func() tea.Msg {
3363 return RefMsg(ref)
3364 }
3365 }
3366+
3367+// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg.
3368+func UpdateRefCmd(repo backend.Repository) tea.Cmd {
3369+ return func() tea.Msg {
3370+ r, err := repo.Repository()