From 868ca2125ac5568f162543751cdb5f89918996e4 Mon Sep 17 00:00:00 2001 From: jolheiser Date: Mon, 15 Jan 2024 16:26:51 -0600 Subject: [PATCH] initial commit Signed-off-by: jolheiser --- .gitignore | 2 + .helix/languages.toml | 5 + README.md | 32 ++++ assets/assets.go | 19 +++ assets/email.svg | 1 + assets/link.svg | 1 + assets/ugit.svg | 10 ++ cmd/ugitd/args.go | 86 +++++++++- cmd/ugitd/main.go | 73 ++++++++- flake.lock | 84 +++------- flake.nix | 151 ++++++++++++++++-- go.mod | 40 ++--- go.sum | 81 ++++++---- internal/git/git.go | 139 ++++++++++++++++ internal/git/meta.go | 62 ++++++++ internal/git/protocol.go | 223 ++++++++++++++++++++++++++ internal/git/repo.go | 103 ++++++++++++ internal/html/base.templ | 24 +++ internal/html/base_templ.go | 86 ++++++++++ internal/html/chroma.go | 42 +++++ internal/html/generate.css | 44 ++++++ internal/html/generate.go | 96 ++++++++++++ internal/html/index.templ | 72 +++++++++ internal/html/index_templ.go | 261 +++++++++++++++++++++++++++++++ internal/html/markdown.go | 66 ++++++++ internal/html/readme.templ | 10 ++ internal/html/readme_templ.go | 47 ++++++ internal/html/repo.templ | 23 +++ internal/html/repo_file.templ | 68 ++++++++ internal/html/repo_file_templ.go | 164 +++++++++++++++++++ internal/html/repo_templ.go | 125 +++++++++++++++ internal/html/repo_tree.templ | 51 ++++++ internal/html/repo_tree_templ.go | 199 +++++++++++++++++++++++ internal/html/tailwind.config.js | 7 + internal/html/tailwind.go | 9 ++ internal/http/git.go | 50 ++++++ internal/http/http.go | 121 ++++++++++++++ internal/http/httperr/httperr.go | 45 ++++++ internal/http/index.go | 63 ++++++++ internal/http/repo.go | 109 +++++++++++++ internal/http/session.go | 32 ++++ internal/ssh/ssh.go | 42 +++-- internal/ssh/wish.go | 164 +++++++++++++++++++ 43 files changed, 2997 insertions(+), 135 deletions(-) create mode 100644 .helix/languages.toml create mode 100644 README.md create mode 100644 assets/assets.go create mode 100644 assets/email.svg create mode 100644 assets/link.svg create mode 100644 assets/ugit.svg create mode 100644 internal/git/git.go create mode 100644 internal/git/meta.go create mode 100644 internal/git/protocol.go create mode 100644 internal/git/repo.go create mode 100644 internal/html/base.templ create mode 100644 internal/html/base_templ.go create mode 100644 internal/html/chroma.go create mode 100644 internal/html/generate.css create mode 100644 internal/html/generate.go create mode 100644 internal/html/index.templ create mode 100644 internal/html/index_templ.go create mode 100644 internal/html/markdown.go create mode 100644 internal/html/readme.templ create mode 100644 internal/html/readme_templ.go create mode 100644 internal/html/repo.templ create mode 100644 internal/html/repo_file.templ create mode 100644 internal/html/repo_file_templ.go create mode 100644 internal/html/repo_templ.go create mode 100644 internal/html/repo_tree.templ create mode 100644 internal/html/repo_tree_templ.go create mode 100644 internal/html/tailwind.config.js create mode 100644 internal/html/tailwind.go create mode 100644 internal/http/git.go create mode 100644 internal/http/http.go create mode 100644 internal/http/httperr/httperr.go create mode 100644 internal/http/index.go create mode 100644 internal/http/repo.go create mode 100644 internal/http/session.go create mode 100644 internal/ssh/wish.go diff --git a/.gitignore b/.gitignore index f53c2fd..9b0fc31 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /ugit* +.ssh/ +.ugit/ diff --git a/.helix/languages.toml b/.helix/languages.toml new file mode 100644 index 0000000..adcc7b2 --- /dev/null +++ b/.helix/languages.toml @@ -0,0 +1,5 @@ +[[language]] +name = "templ" +language-id = "html" +language-servers = ["templ", "vscode-html-language-server", "tailwindcss-ls"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b526756 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# ugit + +ugit logo + +Minimal git server + +ugit allows cloning via HTTPS/SSH, but can only be pushed to via SSH. + +There are no plans to directly support issues or PR workflows, although webhooks are planned and auxillary software may be created to facilitate these things. +For now, if you wish to collaborate, please send me patches at [john+ugit@jolheiser.com](mailto:john+ugit@jolheiser.com). + +Currently all HTML is allowed in markdown, ugit is intended to be run by/for a trusted user. + +## Getting your public SSH keys from another forge + +Using GitHub as an example (although Gitea/GitLab should have the same URL scheme) + +Ba/sh +```sh +curl https://github.com/.keys > path/to/authorized_keys +``` + +Nushell +```sh +http get https://github.com/.keys | save --force path/to/authorized_keys +``` + +## License + +[MIT](LICENSE) + +Lots of inspiration and some starting code used from [wish](https://github.com/charmbracelet/wish) [(MIT)](https://github.com/charmbracelet/wish/blob/3e6f92a166118390484ce4a0904114b375b9e485/LICENSE) and [legit](https://github.com/icyphox/legit) [(MIT)](https://github.com/icyphox/legit/blob/bdfc973207a67a3b217c130520d53373d088763c/license). diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000..a69ac02 --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,19 @@ +package assets + +import "embed" + +var ( + //go:embed *.svg + Icons embed.FS + LinkIcon = must("link.svg") + EmailIcon = must("email.svg") + LogoIcon = must("ugit.svg") +) + +func must(path string) []byte { + content, err := Icons.ReadFile(path) + if err != nil { + panic(err) + } + return content +} diff --git a/assets/email.svg b/assets/email.svg new file mode 100644 index 0000000..9703d2b --- /dev/null +++ b/assets/email.svg @@ -0,0 +1 @@ +email icon diff --git a/assets/link.svg b/assets/link.svg new file mode 100644 index 0000000..f0a10d1 --- /dev/null +++ b/assets/link.svg @@ -0,0 +1 @@ +link icon diff --git a/assets/ugit.svg b/assets/ugit.svg new file mode 100644 index 0000000..18146d5 --- /dev/null +++ b/assets/ugit.svg @@ -0,0 +1,10 @@ + + + ugit icon + + + + + + + diff --git a/cmd/ugitd/args.go b/cmd/ugitd/args.go index 6b4d999..a1a0e46 100644 --- a/cmd/ugitd/args.go +++ b/cmd/ugitd/args.go @@ -2,19 +2,97 @@ package main import ( "flag" + "fmt" + "strings" "github.com/peterbourgon/ff/v3" "github.com/peterbourgon/ff/v3/ffyaml" ) -type args struct { - db string +type cliArgs struct { + Debug bool + RepoDir string + SSH sshArgs + HTTP httpArgs + Meta metaArgs + Profile profileArgs } -func parseArgs(args []string) (a args, e error) { +type sshArgs struct { + AuthorizedKeys string + CloneURL string + Port int + HostKey string +} + +type httpArgs struct { + CloneURL string + Port int +} + +type metaArgs struct { + Title string + Description string +} + +type profileArgs struct { + Username string + Email string + Links []profileLink +} + +type profileLink struct { + Name string + URL string +} + +func parseArgs(args []string) (c cliArgs, e error) { fs := flag.NewFlagSet("ugitd", flag.ContinueOnError) fs.String("config", "ugit.yaml", "Path to config file") - return a, ff.Parse(fs, args, + + c = cliArgs{ + RepoDir: ".ugit", + SSH: sshArgs{ + AuthorizedKeys: ".ssh/authorized_keys", + CloneURL: "ssh://localhost:8448", + Port: 8448, + HostKey: ".ssh/ugit_ed25519", + }, + HTTP: httpArgs{ + CloneURL: "http://localhost:8449", + Port: 8449, + }, + Meta: metaArgs{ + Title: "ugit", + Description: "Minimal git server", + }, + } + + fs.BoolVar(&c.Debug, "debug", c.Debug, "Debug logging") + fs.StringVar(&c.RepoDir, "repo-dir", c.RepoDir, "Path to directory containing repositories") + fs.StringVar(&c.SSH.AuthorizedKeys, "ssh.authorized-keys", c.SSH.AuthorizedKeys, "Path to authorized_keys") + fs.StringVar(&c.SSH.CloneURL, "ssh.clone-url", c.SSH.CloneURL, "SSH clone URL base") + fs.IntVar(&c.SSH.Port, "ssh.port", c.SSH.Port, "SSH port") + fs.StringVar(&c.SSH.HostKey, "ssh.host-key", c.SSH.HostKey, "SSH host key (created if it doesn't exist)") + fs.StringVar(&c.HTTP.CloneURL, "http.clone-url", c.HTTP.CloneURL, "HTTP clone URL base") + fs.IntVar(&c.HTTP.Port, "http.port", c.HTTP.Port, "HTTP port") + fs.StringVar(&c.Meta.Title, "meta.title", c.Meta.Title, "App title") + fs.StringVar(&c.Meta.Description, "meta.description", c.Meta.Description, "App description") + fs.StringVar(&c.Profile.Username, "profile.username", c.Profile.Username, "Username for index page") + fs.StringVar(&c.Profile.Email, "profile.email", c.Profile.Email, "Email for index page") + fs.Func("profile.links", "Link(s) for index page", func(s string) error { + parts := strings.SplitN(s, ",", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid profile link %q", s) + } + c.Profile.Links = append(c.Profile.Links, profileLink{ + Name: parts[0], + URL: parts[1], + }) + return nil + }) + + return c, ff.Parse(fs, args, ff.WithEnvVarPrefix("UGIT"), ff.WithConfigFileFlag("config"), ff.WithAllowMissingConfigFile(true), diff --git a/cmd/ugitd/main.go b/cmd/ugitd/main.go index fc9a7bf..3286e60 100644 --- a/cmd/ugitd/main.go +++ b/cmd/ugitd/main.go @@ -1,14 +1,85 @@ package main import ( + "errors" + "flag" "fmt" "os" + "os/signal" + + "go.jolheiser.com/ugit/internal/http" + "go.jolheiser.com/ugit/internal/ssh" + + "github.com/charmbracelet/log" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-git/go-git/v5/utils/trace" ) func main() { args, err := parseArgs(os.Args[1:]) if err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } panic(err) } - fmt.Println(args) + + if args.Debug { + trace.SetTarget(trace.Packet) + log.SetLevel(log.DebugLevel) + } else { + middleware.DefaultLogger = http.NoopLogger + ssh.DefaultLogger = ssh.NoopLogger + } + + if err := os.MkdirAll(args.RepoDir, os.ModePerm); err != nil { + panic(err) + } + + sshSettings := ssh.Settings{ + AuthorizedKeys: args.SSH.AuthorizedKeys, + CloneURL: args.SSH.CloneURL, + Port: args.SSH.Port, + HostKey: args.SSH.HostKey, + RepoDir: args.RepoDir, + } + sshSrv, err := ssh.New(sshSettings) + if err != nil { + panic(err) + } + go func() { + fmt.Printf("SSH listening on ssh://localhost:%d\n", sshSettings.Port) + if err := sshSrv.ListenAndServe(); err != nil { + panic(err) + } + }() + + httpSettings := http.Settings{ + Title: args.Meta.Title, + Description: args.Meta.Description, + CloneURL: args.HTTP.CloneURL, + Port: args.HTTP.Port, + RepoDir: args.RepoDir, + Profile: http.Profile{ + Username: args.Profile.Username, + Email: args.Profile.Email, + }, + } + for _, link := range args.Profile.Links { + httpSettings.Profile.Links = append(httpSettings.Profile.Links, http.Link{ + Name: link.Name, + URL: link.URL, + }) + } + httpSrv := http.New(httpSettings) + go func() { + fmt.Printf("HTTP listening on http://localhost:%d\n", httpSettings.Port) + if err := httpSrv.ListenAndServe(); err != nil { + panic(err) + } + }() + + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Kill, os.Interrupt) + <-ch } diff --git a/flake.lock b/flake.lock index 43e6a8f..ac832b8 100644 --- a/flake.lock +++ b/flake.lock @@ -1,78 +1,26 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1694529238, - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", - "type": "github" - }, - "original": { - "id": "flake-utils", - "type": "indirect" - } - }, "nixpkgs": { "locked": { - "lastModified": 1701020769, - "narHash": "sha256-4YzCo7xMzkG/t/VlTHqOg9hvXCvqdWYDX/jpF0h+Wr8=", + "lastModified": 1704161960, + "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", "owner": "nixos", "repo": "nixpkgs", - "rev": "b608fc233c0592210250974d1bb3c11dfaf95e58", + "rev": "63143ac2c9186be6d9da6035fa22620018c85932", "type": "github" }, "original": { "owner": "nixos", + "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } }, - "nur": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1700580516, - "narHash": "sha256-h72i6afGKreU+DjpZ6+qersarYYp4YjX+DBQ+MQkOG4=", - "ref": "refs/heads/main", - "rev": "a68a81cbc743e84aaee331ae7e58699398dd732d", - "revCount": 167, - "type": "git", - "url": "https://git.jojodev.com/jolheiser/nur" - }, - "original": { - "type": "git", - "url": "https://git.jojodev.com/jolheiser/nur" - } - }, "root": { "inputs": { - "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "nur": "nur", - "tailwind-ctp": "tailwind-ctp" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "tailwind-ctp": "tailwind-ctp", + "tailwind-ctp-lsp": "tailwind-ctp-lsp" } }, "tailwind-ctp": { @@ -94,6 +42,26 @@ "type": "git", "url": "https://git.jojodev.com/jolheiser/tailwind-ctp" } + }, + "tailwind-ctp-lsp": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1699401590, + "narHash": "sha256-nx8ExuBRUux9eXSUgkWp1LJMvA3dmA76+2xggZjHTU0=", + "ref": "refs/heads/master", + "rev": "b321333ad08bf21db242f246b10ad4a50b8fc8a0", + "revCount": 848, + "type": "git", + "url": "https://git.jojodev.com/jolheiser/tailwind-ctp-intellisense" + }, + "original": { + "type": "git", + "url": "https://git.jojodev.com/jolheiser/tailwind-ctp-intellisense" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index e2c6445..cdcff1f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,36 +1,163 @@ { + description = "Minimal git server"; + inputs = { - nixpkgs.url = "github:nixos/nixpkgs"; - nur = { - url = "git+https://git.jojodev.com/jolheiser/nur"; - inputs.nixpkgs.follows = "nixpkgs"; - }; + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; tailwind-ctp = { url = "git+https://git.jojodev.com/jolheiser/tailwind-ctp"; inputs.nixpkgs.follows = "nixpkgs"; }; + tailwind-ctp-lsp = { + url = "git+https://git.jojodev.com/jolheiser/tailwind-ctp-intellisense"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = { self, - flake-utils, nixpkgs, - nur, tailwind-ctp, + tailwind-ctp-lsp, } @ inputs: let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; - nur = inputs.nur.packages.${system}; tailwind-ctp = inputs.tailwind-ctp.packages.${system}.default; + tailwind-ctp-lsp = inputs.tailwind-ctp-lsp.packages.${system}.default; + ugit = pkgs.buildGoModule rec { + pname = "ugitd"; + version = "0.0.1"; + src = pkgs.nix-gitignore.gitignoreSource [] (builtins.path { + name = pname; + path = ./.; + }); + subPackages = ["cmd/ugitd"]; + vendorHash = "sha256-E4cwC6c0d+HvHldqGYiWdPEdS2fch6imvAXzxb2MMdY="; + meta = with pkgs.lib; { + description = "Minimal git server"; + homepage = "https://git.jolheiser.com/ugit"; + maintainers = with maintainers; [jolheiser]; + mainProgram = "ugitd"; + }; + }; in { + packages.${system}.default = ugit; devShells.${system}.default = pkgs.mkShell { nativeBuildInputs = with pkgs; [ - # go - # gopls - nur.templ + go + gopls + templ tailwind-ctp - sqlc + tailwind-ctp-lsp + vscode-langservers-extracted ]; }; + nixosModules.default = { + pkgs, + lib, + config, + ... + }: let + cfg = config.services.ugit; + configFile = pkgs.writeText "ugit.yaml" cfg.configFile; + authorizedKeysFile = pkgs.writeText "ugit_keys" (builtins.concatStringsSep "\n" cfg.authorizedKeys); + in { + options = with lib; { + services.ugit = { + enable = mkEnableOption "Enable ugit"; + + package = mkOption { + type = types.package; + description = "ugit package to use"; + default = ugit; + }; + + repoDir = mkOption { + type = types.str; + description = "where ugit stores repositories"; + default = "/var/lib/ugit/repos"; + }; + + authorizedKeys = mkOption { + type = types.listOf types.str; + description = "list of keys to use for authorized_keys"; + default = []; + }; + + authorizedKeysFile = mkOption { + type = types.str; + description = "path to authorized_keys file ugit uses for auth"; + default = "/var/lib/ugit/authorized_keys"; + }; + + hostKeyFile = mkOption { + type = types.str; + description = "path to host key file (will be created if it doesn't exist)"; + default = "/var/lib/ugit/ugit_ed25519"; + }; + + configFile = mkOption { + type = types.str; + default = ""; + description = "config.yaml contents"; + }; + + user = mkOption { + type = types.str; + default = "ugit"; + description = "User account under which ugit runs"; + }; + + group = mkOption { + type = types.str; + default = "ugit"; + description = "Group account under which ugit runs"; + }; + + debug = mkOption { + type = types.bool; + default = false; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + }; + }; + }; + config = lib.mkIf cfg.enable { + users.users."${cfg.user}" = { + home = "/var/lib/ugit"; + createHome = true; + group = "${cfg.group}"; + isSystemUser = true; + isNormalUser = false; + description = "user for ugit service"; + }; + users.groups."${cfg.group}" = {}; + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [8448 8449]; + }; + + systemd.services.ugit = { + enable = true; + script = let + authorizedKeysPath = + if (builtins.length cfg.authorizedKeys) > 0 + then authorizedKeysFile + else cfg.authorizedKeysFile; + args = ["--config=${configFile}" "--repo-dir=${cfg.repoDir}" "--ssh.authorized-keys=${authorizedKeysPath}" "--ssh.host-key=${cfg.hostKeyFile}"] ++ lib.optionals cfg.debug ["--debug"]; + in "${cfg.package}/bin/ugitd ${builtins.concatStringsSep " " args}"; + wantedBy = ["multi-user.target"]; + after = ["network-online.target"]; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + Restart = "always"; + RestartSec = "15"; + WorkingDirectory = "/var/lib/ugit"; + }; + }; + }; + }; }; } diff --git a/go.mod b/go.mod index 6b64d7e..3af97fb 100644 --- a/go.mod +++ b/go.mod @@ -3,49 +3,53 @@ module go.jolheiser.com/ugit go 1.20 require ( + github.com/a-h/templ v0.2.513 + github.com/alecthomas/chroma/v2 v2.12.0 + github.com/charmbracelet/log v0.2.5 github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 github.com/charmbracelet/wish v1.2.0 + github.com/dustin/go-humanize v1.0.1 + github.com/go-chi/chi/v5 v5.0.11 + github.com/go-git/go-billy/v5 v5.5.0 + github.com/go-git/go-git/v5 v5.11.0 + github.com/peterbourgon/ff/v3 v3.4.0 + github.com/yuin/goldmark v1.6.0 + github.com/yuin/goldmark-emoji v1.0.2 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect - github.com/acomagu/bufpipe v1.0.4 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/keygen v0.5.0 // indirect github.com/charmbracelet/lipgloss v0.9.1 // indirect - github.com/charmbracelet/log v0.2.5 // indirect - github.com/cloudflare/circl v1.3.3 // indirect + github.com/cloudflare/circl v1.3.6 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/dlclark/regexp2 v1.10.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.5.0 // indirect - github.com/go-git/go-git/v5 v5.10.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/sergi/go-diff v1.1.0 // indirect - github.com/skeema/knownhosts v1.2.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/skeema/knownhosts v1.2.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/net v0.17.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/tools v0.16.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) - -require ( - github.com/mattn/go-isatty v0.0.18 // indirect - github.com/peterbourgon/ff/v3 v3.4.0 - golang.org/x/mod v0.12.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/tools v0.13.0 // indirect -) diff --git a/go.sum b/go.sum index fe3c6a3..3e36031 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,16 @@ dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= -github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= -github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/a-h/templ v0.2.513 h1:ZmwGAOx4NYllnHy+FTpusc4+c5msoMpPIYX0Oy3dNqw= +github.com/a-h/templ v0.2.513/go.mod h1:9gZxTLtRzM3gQxO8jr09Na0v8/jfliS97S9W5SScanM= +github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw= +github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -23,29 +29,39 @@ github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM= github.com/charmbracelet/wish v1.2.0 h1:h5Wj9pr97IQz/l4gM5Xep2lXcY/YM+6O2RC2o3x0JIQ= github.com/charmbracelet/wish v1.2.0/go.mod h1:JX3fC+178xadJYAhPu6qWtVDpJTwpnFvpdjz9RKJlUE= -github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg= +github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git/v5 v5.10.0 h1:F0x3xXrAWmhwtzoCokU4IMPcBdncG+HAAqi9FcOOjbQ= -github.com/go-git/go-git/v5 v5.10.0/go.mod h1:1FOZ/pQnqw24ghP2n7cunVl0ON55BsjPYvhWHvZGhoo= +github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= +github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -57,10 +73,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= -github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -81,30 +95,39 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= -github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= +github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= +github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= +github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= +github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -112,12 +135,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -132,15 +155,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -148,13 +171,13 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= +golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -162,8 +185,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..141ced5 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,139 @@ +package git + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "sort" + + "github.com/dustin/go-humanize" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// EnsureRepo ensures that the repo exists in the given directory +func EnsureRepo(dir string, repo string) error { + exists, err := PathExists(dir) + if err != nil { + return err + } + if !exists { + err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0o700)) + if err != nil { + return err + } + } + rp := filepath.Join(dir, repo) + exists, err = PathExists(rp) + if err != nil { + return err + } + if !exists { + _, err := git.PlainInit(rp, true) + if err != nil { + return err + } + } + return nil +} + +// PathExists checks if a path exists and returns true if it does +func PathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return true, err +} + +func (r Repo) Tree(ref string) (*object.Tree, error) { + g, err := r.Git() + if err != nil { + return nil, err + } + + hash, err := g.ResolveRevision(plumbing.Revision(ref)) + if err != nil { + return nil, err + } + + c, err := g.CommitObject(*hash) + if err != nil { + return nil, err + } + + return c.Tree() +} + +type FileInfo struct { + Path string + IsDir bool + Mode string + Size string +} + +func (f FileInfo) Name() string { + return filepath.Base(f.Path) +} + +func (r Repo) Dir(ref, path string) ([]FileInfo, error) { + t, err := r.Tree(ref) + if err != nil { + return nil, err + } + if path != "" { + t, err = t.Tree(path) + if err != nil { + return nil, err + } + } + + fis := make([]FileInfo, 0) + for _, entry := range t.Entries { + fm, err := entry.Mode.ToOSFileMode() + if err != nil { + return nil, err + } + size, err := t.Size(entry.Name) + if err != nil { + return nil, err + } + fis = append(fis, FileInfo{ + Path: filepath.Join(path, entry.Name), + IsDir: fm.IsDir(), + Mode: fm.String(), + Size: humanize.Bytes(uint64(size)), + }) + } + sort.Slice(fis, func(i, j int) bool { + fi1 := fis[i] + fi2 := fis[j] + return (fi1.IsDir && !fi2.IsDir) || fi1.Name() < fi2.Name() + }) + + return fis, nil +} + +func (r Repo) FileContent(ref, file string) (string, error) { + t, err := r.Tree(ref) + if err != nil { + return "", err + } + + f, err := t.File(file) + if err != nil { + return "", err + } + + content, err := f.Contents() + if err != nil { + return "", err + } + + return content, nil +} diff --git a/internal/git/meta.go b/internal/git/meta.go new file mode 100644 index 0000000..04f7f1b --- /dev/null +++ b/internal/git/meta.go @@ -0,0 +1,62 @@ +package git + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" +) + +type RepoMeta struct { + Description string `json:"description"` + Private bool `json:"private"` +} + +func (m *RepoMeta) Update(meta RepoMeta) error { + data, err := json.Marshal(meta) + if err != nil { + return err + } + return json.Unmarshal(data, m) +} + +func (r Repo) metaPath() string { + return filepath.Join(r.path, "ugit.json") +} + +func (r Repo) SaveMeta() error { + // Compatibility with gitweb, because why not + // Ignoring the error because it's not technically detrimental to ugit + desc, err := os.Create(filepath.Join(r.path, "description")) + if err == nil { + defer desc.Close() + desc.WriteString(r.Meta.Description) + } + + fi, err := os.Create(r.metaPath()) + if err != nil { + return err + } + defer fi.Close() + return json.NewEncoder(fi).Encode(r.Meta) +} + +func ensureJSONFile(path string) error { + _, err := os.Stat(path) + if err == nil { + return nil + } + if !errors.Is(err, fs.ErrNotExist) { + return err + } + fi, err := os.Create(path) + if err != nil { + return err + } + defer fi.Close() + if _, err := fi.WriteString(`{"private":true}`); err != nil { + return err + } + return nil +} diff --git a/internal/git/protocol.go b/internal/git/protocol.go new file mode 100644 index 0000000..60b927d --- /dev/null +++ b/internal/git/protocol.go @@ -0,0 +1,223 @@ +package git + +import ( + "bufio" + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/format/pktline" + "github.com/go-git/go-git/v5/plumbing/protocol/packp" + "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" + "github.com/go-git/go-git/v5/plumbing/serverinfo" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/server" + "github.com/go-git/go-git/v5/storage/filesystem" + "github.com/go-git/go-git/v5/utils/ioutil" +) + +type ReadWriteContexter interface { + io.ReadWriteCloser + Context() context.Context +} + +type Protocol struct { + endpoint *transport.Endpoint + server transport.Transport +} + +func NewProtocol(repoPath string) (Protocol, error) { + endpoint, err := transport.NewEndpoint("/") + if err != nil { + return Protocol{}, err + } + fs := osfs.New(repoPath) + loader := server.NewFilesystemLoader(fs) + gitServer := server.NewServer(loader) + return Protocol{ + endpoint: endpoint, + server: gitServer, + }, nil +} + +func (p Protocol) HTTPInfoRefs(rwc ReadWriteContexter) error { + session, err := p.server.NewUploadPackSession(p.endpoint, nil) + if err != nil { + return err + } + defer ioutil.CheckClose(rwc, &err) + return p.infoRefs(rwc, session, "# service=git-upload-pack") +} + +func (p Protocol) infoRefs(rwc ReadWriteContexter, session transport.UploadPackSession, prefix string) error { + ar, err := session.AdvertisedReferencesContext(rwc.Context()) + if err != nil { + return err + } + + if prefix != "" { + ar.Prefix = [][]byte{ + []byte(prefix), + pktline.Flush, + } + } + + if err := ar.Encode(rwc); err != nil { + return err + } + + return nil +} + +func (p Protocol) HTTPUploadPack(rwc ReadWriteContexter) error { + return p.uploadPack(rwc, false) +} + +func (p Protocol) SSHUploadPack(rwc ReadWriteContexter) error { + return p.uploadPack(rwc, true) +} + +func (p Protocol) uploadPack(rwc ReadWriteContexter, ssh bool) error { + session, err := p.server.NewUploadPackSession(p.endpoint, nil) + if err != nil { + return err + } + defer ioutil.CheckClose(rwc, &err) + + if ssh { + if err := p.infoRefs(rwc, session, ""); err != nil { + return err + } + } + + req := packp.NewUploadPackRequest() + if err := req.Decode(rwc); err != nil { + return err + } + + var resp *packp.UploadPackResponse + resp, err = session.UploadPack(rwc.Context(), req) + if err != nil { + return err + } + + if err := resp.Encode(rwc); err != nil { + return fmt.Errorf("could not encode upload pack: %w", err) + } + + return nil +} + +func (p Protocol) SSHReceivePack(rwc ReadWriteContexter, repo *Repo) error { + buf := bufio.NewReader(rwc) + + session, err := p.server.NewReceivePackSession(p.endpoint, nil) + if err != nil { + return err + } + + ar, err := session.AdvertisedReferencesContext(rwc.Context()) + if err != nil { + return fmt.Errorf("internal error in advertised references: %w", err) + } + _ = ar.Capabilities.Set(capability.PushOptions) + _ = ar.Capabilities.Set("no-thin") + + if err := ar.Encode(rwc); err != nil { + return fmt.Errorf("error in advertised references encoding: %w", err) + } + + req := packp.NewReferenceUpdateRequest() + _ = req.Capabilities.Set(capability.ReportStatus) + if err := req.Decode(buf); err != nil { + // FIXME this is a hack, but go-git doesn't accept a 0000 if there are no refs to update + if !strings.EqualFold(err.Error(), "capabilities delimiter not found") { + return fmt.Errorf("error decoding: %w", err) + } + } + + // FIXME also a hack, if the next bytes are PACK then we have a packfile, otherwise assume it's push options + peek, err := buf.Peek(4) + if err != nil { + return err + } + if string(peek) != "PACK" { + s := pktline.NewScanner(buf) + for s.Scan() { + val := string(s.Bytes()) + if val == "" { + break + } + if s.Err() != nil { + return s.Err() + } + parts := strings.SplitN(val, "=", 2) + req.Options = append(req.Options, &packp.Option{ + Key: parts[0], + Value: parts[1], + }) + } + } + + if err := handlePushOptions(repo, req.Options); err != nil { + return fmt.Errorf("could not handle push options: %w", err) + } + + // FIXME if there are only delete commands, there is no packfile and ReceivePack will block forever + noPack := true + for _, c := range req.Commands { + if c.Action() != packp.Delete { + noPack = false + break + } + } + if noPack { + req.Packfile = nil + } + + rs, err := session.ReceivePack(rwc.Context(), req) + if err != nil { + return fmt.Errorf("error in receive pack: %w", err) + } + + if err := rs.Encode(rwc); err != nil { + return fmt.Errorf("could not encode receive pack: %w", err) + } + + return nil +} + +func handlePushOptions(repo *Repo, opts []*packp.Option) error { + var changed bool + for _, opt := range opts { + switch strings.ToLower(opt.Key) { + case "desc", "description": + changed = repo.Meta.Description != opt.Value + repo.Meta.Description = opt.Value + case "private": + private, err := strconv.ParseBool(opt.Value) + if err != nil { + continue + } + changed = repo.Meta.Private != private + repo.Meta.Private = private + } + } + if changed { + return repo.SaveMeta() + } + return nil +} + +func UpdateServerInfo(repo string) error { + r, err := git.PlainOpen(repo) + if err != nil { + return err + } + fs := r.Storer.(*filesystem.Storage).Filesystem() + return serverinfo.UpdateServerInfo(r.Storer, fs) +} diff --git a/internal/git/repo.go b/internal/git/repo.go new file mode 100644 index 0000000..6b7c997 --- /dev/null +++ b/internal/git/repo.go @@ -0,0 +1,103 @@ +package git + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type Repo struct { + path string + Meta RepoMeta +} + +func (r Repo) Name() string { + return strings.TrimSuffix(filepath.Base(r.path), ".git") +} + +func NewRepo(dir, name string) (*Repo, error) { + if !strings.HasSuffix(name, ".git") { + name += ".git" + } + r := &Repo{ + path: filepath.Join(dir, name), + } + + _, err := os.Stat(r.path) + if err != nil { + return nil, err + } + + if err := ensureJSONFile(r.metaPath()); err != nil { + return nil, err + } + fi, err := os.Open(r.metaPath()) + if err != nil { + return nil, err + } + defer fi.Close() + + if err := json.NewDecoder(fi).Decode(&r.Meta); err != nil { + return nil, err + } + + return r, nil +} + +// DefaultBranch returns the branch referenced by HEAD, setting it if needed +func (r Repo) DefaultBranch() (string, error) { + repo, err := r.Git() + if err != nil { + return "", err + } + + ref, err := repo.Head() + if err != nil { + if !errors.Is(err, plumbing.ErrReferenceNotFound) { + return "", err + } + brs, err := repo.Branches() + if err != nil { + return "", err + } + defer brs.Close() + fb, err := brs.Next() + if err != nil { + return "", err + } + // Rename the default branch to the first branch available + ref = fb + sym := plumbing.NewSymbolicReference(plumbing.HEAD, fb.Name()) + if err := repo.Storer.SetReference(sym); err != nil { + return "", err + } + } + + return strings.TrimPrefix(ref.Name().String(), "refs/heads/"), nil +} + +// Git allows access to the git repository +func (r Repo) Git() (*git.Repository, error) { + return git.PlainOpen(r.path) +} + +// LastCommit returns the last commit of the repo +func (r Repo) LastCommit() (*object.Commit, error) { + repo, err := r.Git() + if err != nil { + return nil, err + } + + head, err := repo.Head() + if err != nil { + return nil, err + } + + return repo.CommitObject(head.Hash()) +} diff --git a/internal/html/base.templ b/internal/html/base.templ new file mode 100644 index 0000000..5d2e3b9 --- /dev/null +++ b/internal/html/base.templ @@ -0,0 +1,24 @@ +package html + +type BaseContext struct { + Title string + Description string +} + +templ base(bc BaseContext) { + + + + { bc.Title } + + + + + + +

Home

+ { children... } + + +} + diff --git a/internal/html/base_templ.go b/internal/html/base_templ.go new file mode 100644 index 0000000..d5f04c2 --- /dev/null +++ b/internal/html/base_templ.go @@ -0,0 +1,86 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.501 +package html + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +type BaseContext struct { + Title string + Description string +} + +func base(bc BaseContext) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `base.templ`, Line: 11, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var3 := `Home` + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/html/chroma.go b/internal/html/chroma.go new file mode 100644 index 0000000..1ea0601 --- /dev/null +++ b/internal/html/chroma.go @@ -0,0 +1,42 @@ +package html + +import ( + "io" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" +) + +var ( + Formatter = html.New( + html.WithLineNumbers(true), + html.WithLinkableLineNumbers(true, "L"), + html.WithClasses(true), + html.LineNumbersInTable(true), + ) + Code = code{} +) + +type code struct{} + +func (c code) Convert(source []byte, fileName string, writer io.Writer) error { + lexer := lexers.Match(fileName) + if lexer == nil { + lexer = lexers.Fallback + } + lexer = chroma.Coalesce(lexer) + + style := styles.Get("catppuccin-mocha") + if style == nil { + style = styles.Fallback + } + + iter, err := lexer.Tokenise(nil, string(source)) + if err != nil { + return err + } + + return Formatter.Format(writer, style, iter) +} diff --git a/internal/html/generate.css b/internal/html/generate.css new file mode 100644 index 0000000..34d0aef --- /dev/null +++ b/internal/html/generate.css @@ -0,0 +1,44 @@ +.markdown * { + all: revert; + color: rgb(var(--ctp-text)); +} + +.markdown a { + color: rgb(var(--ctp-blue)); + text-decoration-line: underline; + text-decoration-style: dashed; +} + +.markdown a:hover { + text-decoration-style: solid; +} + +.chroma { + font-size: small; +} + +.chroma * { + background-color: rgb(var(--ctp-base)) !important; +} + +.chroma table { + border-spacing: 5px 0 !important; +} + +.chroma .lnt { + color: rgb(var(--ctp-subtext1)) !important; +} + +.chroma .lnt:target, +.chroma .lnt:focus { + color: rgb(var(--ctp-subtext0)) !important; +} + +.chroma .line { + white-space: break-spaces; +} + +.chroma .line.active, +.chroma .line.active * { + background: rgb(var(--ctp-surface0)) !important; +} \ No newline at end of file diff --git a/internal/html/generate.go b/internal/html/generate.go new file mode 100644 index 0000000..b4c1a44 --- /dev/null +++ b/internal/html/generate.go @@ -0,0 +1,96 @@ +//go:build generate + +package main + +import ( + "bytes" + _ "embed" + "fmt" + "go/format" + "os" + "os/exec" + + "go.jolheiser.com/ugit/internal/html" + + "github.com/alecthomas/chroma/v2/styles" +) + +var ( + tailwindCSS = ` +@tailwind base; +@tailwind components; +@tailwind utilities; +` + //go:embed generate.css + otherCSS string +) + +//go:generate templ generate +//go:generate go run generate.go +func main() { + if err := tailwind(); err != nil { + panic(err) + } +} + +func tailwind() error { + fmt.Println("generating tailwind...") + + tmp, err := os.CreateTemp(os.TempDir(), "ugit-tailwind*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.WriteString(tailwindCSS + otherCSS); err != nil { + return err + } + + fmt.Println("generating chroma styles...") + + latte := styles.Get("catppuccin-latte") + if err := html.Formatter.WriteCSS(tmp, latte); err != nil { + return err + } + + tmp.WriteString("@media (prefers-color-scheme: dark) {") + mocha := styles.Get("catppuccin-mocha") + if err := html.Formatter.WriteCSS(tmp, mocha); err != nil { + return err + } + tmp.WriteString("}") + + fmt.Println("finished generating chroma styles") + + tmp.Close() + + styles, err := os.Create("tailwind.go") + if err != nil { + return err + } + defer styles.Close() + + var buf bytes.Buffer + cmd := exec.Command("tailwind-ctp", "-i", tmp.Name(), "--minify") + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return err + } + + code := fmt.Sprintf(`// Code generated by generate.go - DO NOT EDIT. +package html + + import "net/http" + + func TailwindHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css") + w.Write([]byte(%q)) + }`, buf.String()) + formatted, err := format.Source([]byte(code)) + if err != nil { + return err + } + styles.Write(formatted) + + fmt.Println("finished generating tailwind") + return nil +} diff --git a/internal/html/index.templ b/internal/html/index.templ new file mode 100644 index 0000000..96d204d --- /dev/null +++ b/internal/html/index.templ @@ -0,0 +1,72 @@ +package html + +import "go.jolheiser.com/ugit/internal/git" +import "github.com/dustin/go-humanize" +import "go.jolheiser.com/ugit/assets" + +type IndexContext struct { + BaseContext + Profile IndexProfile + CloneURL string + Repos []*git.Repo +} + +type IndexProfile struct { + Username string + Email string + Links []IndexLink +} + +type IndexLink struct { + Name string + URL string +} + +func lastCommit(repo *git.Repo, human bool) string { + c, err := repo.LastCommit() + if err != nil { + return "" + } + if human { + return humanize.Time(c.Author.When) + } + return c.Author.When.Format("01/02/2006 03:04:05 PM") +} + +templ Index(ic IndexContext) { + @base(ic.BaseContext) { +
+

{ ic.Title }

+

{ ic.Description }

+
+
+
+ if ic.Profile.Username != "" { +
{ `@` + ic.Profile.Username }
+ } + if ic.Profile.Email != "" { +
+
@templ.Raw(string(assets.EmailIcon))
+ john.olheiser@gmail.com +
+ } +
+
+ for _, link := range ic.Profile.Links { +
+
@templ.Raw(string(assets.LinkIcon))
+ { link.Name } +
+ } +
+
+ for _, repo := range ic.Repos { + +
{ repo.Meta.Description }
+
{ lastCommit(repo, true) }
+ } +
+
+ } +} + diff --git a/internal/html/index_templ.go b/internal/html/index_templ.go new file mode 100644 index 0000000..15f2004 --- /dev/null +++ b/internal/html/index_templ.go @@ -0,0 +1,261 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.501 +package html + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +import "go.jolheiser.com/ugit/internal/git" +import "github.com/dustin/go-humanize" +import "go.jolheiser.com/ugit/assets" + +type IndexContext struct { + BaseContext + Profile IndexProfile + CloneURL string + Repos []*git.Repo +} + +type IndexProfile struct { + Username string + Email string + Links []IndexLink +} + +type IndexLink struct { + Name string + URL string +} + +func lastCommit(repo *git.Repo, human bool) string { + c, err := repo.LastCommit() + if err != nil { + return "" + } + if human { + return humanize.Time(c.Author.When) + } + return c.Author.When.Format("01/02/2006 03:04:05 PM") +} + +func Index(ic IndexContext) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(ic.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 38, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(ic.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 39, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ic.Profile.Username != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(`@` + ic.Profile.Username) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 44, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if ic.Profile.Email != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(string(assets.EmailIcon)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var6 := `john.olheiser@gmail.com` + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, link := range ic.Profile.Links { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, repo := range ic.Repos { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(repo.Meta.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 64, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lastCommit(repo, true)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 65, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = base(ic.BaseContext).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/html/markdown.go b/internal/html/markdown.go new file mode 100644 index 0000000..e109162 --- /dev/null +++ b/internal/html/markdown.go @@ -0,0 +1,66 @@ +package html + +import ( + "bytes" + "path/filepath" + + "go.jolheiser.com/ugit/internal/git" + + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/yuin/goldmark" + emoji "github.com/yuin/goldmark-emoji" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + goldmarkhtml "github.com/yuin/goldmark/renderer/html" +) + +var Markdown = goldmark.New( + goldmark.WithRendererOptions( + goldmarkhtml.WithUnsafe(), + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithExtensions( + extension.GFM, + emoji.Emoji, + highlighting.NewHighlighting( + highlighting.WithStyle("catppuccin-mocha"), + highlighting.WithFormatOptions( + chromahtml.WithClasses(true), + chromahtml.WithLineNumbers(true), + chromahtml.WithLinkableLineNumbers(true, "md-"), + chromahtml.LineNumbersInTable(true), + ), + ), + ), +) + +func Readme(repo *git.Repo, ref, path string) (string, error) { + var readme string + var err error + for _, md := range []string{"README.md", "readme.md"} { + readme, err = repo.FileContent(ref, filepath.Join(path, md)) + if err == nil { + break + } + } + + if readme != "" { + var buf bytes.Buffer + if err := Markdown.Convert([]byte(readme), &buf); err != nil { + return "", err + } + return buf.String(), nil + } + + for _, md := range []string{"README.txt", "README", "readme.txt", "readme"} { + readme, err = repo.FileContent(ref, filepath.Join(path, md)) + if err == nil { + return readme, nil + } + } + + return "", nil +} diff --git a/internal/html/readme.templ b/internal/html/readme.templ new file mode 100644 index 0000000..cdcc381 --- /dev/null +++ b/internal/html/readme.templ @@ -0,0 +1,10 @@ +package html + +type ReadmeComponentContext struct { + Markdown string +} + +templ readmeComponent(rcc ReadmeComponentContext) { +
@templ.Raw(rcc.Markdown)
+} + diff --git a/internal/html/readme_templ.go b/internal/html/readme_templ.go new file mode 100644 index 0000000..812b724 --- /dev/null +++ b/internal/html/readme_templ.go @@ -0,0 +1,47 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.501 +package html + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +type ReadmeComponentContext struct { + Markdown string +} + +func readmeComponent(rcc ReadmeComponentContext) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(rcc.Markdown).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/html/repo.templ b/internal/html/repo.templ new file mode 100644 index 0000000..6bca601 --- /dev/null +++ b/internal/html/repo.templ @@ -0,0 +1,23 @@ +package html + +import "fmt" + +type RepoHeaderComponentContext struct { + Name string + Ref string + Description string +} + +templ repoHeaderComponent(rhcc RepoHeaderComponentContext) { + if rhcc.Name != "" { +
+ { rhcc.Name } + if rhcc.Ref != "" { + { " " } + { "@" + rhcc.Ref } + } +
+ } +
{ rhcc.Description }
+} + diff --git a/internal/html/repo_file.templ b/internal/html/repo_file.templ new file mode 100644 index 0000000..c589066 --- /dev/null +++ b/internal/html/repo_file.templ @@ -0,0 +1,68 @@ +package html + +type RepoFileContext struct { + BaseContext + RepoHeaderComponentContext + Code string + Path string +} + +templ RepoFile(rfc RepoFileContext) { + @base(rfc.BaseContext) { + @repoHeaderComponent(rfc.RepoHeaderComponentContext) +
Raw{ " - " }{ rfc.Path }@templ.Raw(rfc.Code)
+ } + +} + diff --git a/internal/html/repo_file_templ.go b/internal/html/repo_file_templ.go new file mode 100644 index 0000000..2616d1f --- /dev/null +++ b/internal/html/repo_file_templ.go @@ -0,0 +1,164 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.501 +package html + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +type RepoFileContext struct { + BaseContext + RepoHeaderComponentContext + Code string + Path string +} + +func RepoFile(rfc RepoFileContext) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + templ_7745c5c3_Err = repoHeaderComponent(rfc.RepoHeaderComponentContext).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var3 := `Raw` + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(" - ") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_file.templ`, Line: 12, Col: 153} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(rfc.Path) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_file.templ`, Line: 12, Col: 165} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(rfc.Code).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = base(rfc.BaseContext).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/html/repo_templ.go b/internal/html/repo_templ.go new file mode 100644 index 0000000..3ee5f40 --- /dev/null +++ b/internal/html/repo_templ.go @@ -0,0 +1,125 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.501 +package html + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +import "fmt" + +type RepoHeaderComponentContext struct { + Name string + Ref string + Description string +} + +func repoHeaderComponent(rhcc RepoHeaderComponentContext) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if rhcc.Name != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(rhcc.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 13, Col: 153} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rhcc.Ref != "" { + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(" ") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 15, Col: 9} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("@" + rhcc.Ref) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 16, Col: 195} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(rhcc.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 20, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/html/repo_tree.templ b/internal/html/repo_tree.templ new file mode 100644 index 0000000..204a14d --- /dev/null +++ b/internal/html/repo_tree.templ @@ -0,0 +1,51 @@ +package html + +import ( + "fmt" + "go.jolheiser.com/ugit/internal/git" +) + +type RepoTreeContext struct { + BaseContext + RepoHeaderComponentContext + RepoTreeComponentContext + ReadmeComponentContext + Description string +} + +templ RepoTree(rtc RepoTreeContext) { + @base(rtc.BaseContext) { + @repoHeaderComponent(rtc.RepoHeaderComponentContext) + @repoTreeComponent(rtc.RepoTreeComponentContext) + @readmeComponent(rtc.ReadmeComponentContext) + } +} + +type RepoTreeComponentContext struct { + Repo string + Ref string + Tree []git.FileInfo + Back string +} + +func slashDir(name string, isDir bool) string { + if isDir { + return name + "/" + } + return name +} + +templ repoTreeComponent(rtcc RepoTreeComponentContext) { +
+ if rtcc.Back != "" { +
+ + } + for _, fi := range rtcc.Tree { +
{ fi.Mode }
+
{ fi.Size }
+ + } +
+} + diff --git a/internal/html/repo_tree_templ.go b/internal/html/repo_tree_templ.go new file mode 100644 index 0000000..ecbf44c --- /dev/null +++ b/internal/html/repo_tree_templ.go @@ -0,0 +1,199 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.501 +package html + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +import ( + "fmt" + "go.jolheiser.com/ugit/internal/git" +) + +type RepoTreeContext struct { + BaseContext + RepoHeaderComponentContext + RepoTreeComponentContext + ReadmeComponentContext + Description string +} + +func RepoTree(rtc RepoTreeContext) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + templ_7745c5c3_Err = repoHeaderComponent(rtc.RepoHeaderComponentContext).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = repoTreeComponent(rtc.RepoTreeComponentContext).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = readmeComponent(rtc.ReadmeComponentContext).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = base(rtc.BaseContext).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +type RepoTreeComponentContext struct { + Repo string + Ref string + Tree []git.FileInfo + Back string +} + +func slashDir(name string, isDir bool) string { + if isDir { + return name + "/" + } + return name +} + +func repoTreeComponent(rtcc RepoTreeComponentContext) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rtcc.Back != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, fi := range rtcc.Tree { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fi.Mode) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_tree.templ`, Line: 44, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fi.Size) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_tree.templ`, Line: 45, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/html/tailwind.config.js b/internal/html/tailwind.config.js new file mode 100644 index 0000000..e0a1834 --- /dev/null +++ b/internal/html/tailwind.config.js @@ -0,0 +1,7 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./**/*.templ"], + plugins: [require("@catppuccin/tailwindcss")], +} + + diff --git a/internal/html/tailwind.go b/internal/html/tailwind.go new file mode 100644 index 0000000..8bd8a40 --- /dev/null +++ b/internal/html/tailwind.go @@ -0,0 +1,9 @@ +// Code generated by generate.go - DO NOT EDIT. +package html + +import "net/http" + +func TailwindHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css") + w.Write([]byte("/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:\"\"}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.latte{--ctp-rosewater:220,138,120;--ctp-flamingo:221,120,120;--ctp-pink:234,118,203;--ctp-mauve:136,57,239;--ctp-red:210,15,57;--ctp-maroon:230,69,83;--ctp-peach:254,100,11;--ctp-yellow:223,142,29;--ctp-green:64,160,43;--ctp-teal:23,146,153;--ctp-sky:4,165,229;--ctp-sapphire:32,159,181;--ctp-blue:30,102,245;--ctp-lavender:114,135,253;--ctp-text:76,79,105;--ctp-subtext1:92,95,119;--ctp-subtext0:108,111,133;--ctp-overlay2:124,127,147;--ctp-overlay1:140,143,161;--ctp-overlay0:156,160,176;--ctp-surface2:172,176,190;--ctp-surface1:188,192,204;--ctp-surface0:204,208,218;--ctp-base:239,241,245;--ctp-mantle:230,233,239;--ctp-crust:220,224,232}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-span-5{grid-column:span 5/span 5}.col-span-6{grid-column:span 6/span 6}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-top:2.5rem;margin-bottom:2.5rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mr-1{margin-right:.25rem}.mt-2{margin-top:.5rem}.mt-5{margin-top:1.25rem}.inline-block{display:inline-block}.grid{display:grid}.h-5{height:1.25rem}.w-5{width:1.25rem}.max-w-7xl{max-width:80rem}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.gap-1{gap:.25rem}.rounded{border-radius:.25rem}.bg-base\\/50{background-color:rgba(var(--ctp-base),.5)}.stroke-mauve{stroke:rgb(var(--ctp-mauve))}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.align-middle{vertical-align:middle}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.text-blue{--tw-text-opacity:1;color:rgba(var(--ctp-blue),var(--tw-text-opacity))}.text-mauve{--tw-text-opacity:1;color:rgba(var(--ctp-mauve),var(--tw-text-opacity))}.text-subtext0{--tw-text-opacity:1;color:rgba(var(--ctp-subtext0),var(--tw-text-opacity))}.text-subtext1{--tw-text-opacity:1;color:rgba(var(--ctp-subtext1),var(--tw-text-opacity))}.text-text{--tw-text-opacity:1;color:rgba(var(--ctp-text),var(--tw-text-opacity))}.text-text\\/70{color:rgba(var(--ctp-text),.7)}.text-text\\/80{color:rgba(var(--ctp-text),.8)}.underline{text-decoration-line:underline}.decoration-blue\\/50{text-decoration-color:rgba(var(--ctp-blue),.5)}.decoration-mauve\\/50{text-decoration-color:rgba(var(--ctp-mauve),.5)}.decoration-text\\/50{text-decoration-color:rgba(var(--ctp-text),.5)}.decoration-dashed{text-decoration-style:dashed}.markdown *{all:revert;color:rgb(var(--ctp-text))}.markdown a{color:rgb(var(--ctp-blue));text-decoration-line:underline;text-decoration-style:dashed}.markdown a:hover{text-decoration-style:solid}.chroma{font-size:small}.chroma *{background-color:rgb(var(--ctp-base))!important}.chroma table{border-spacing:5px 0!important}.chroma .lnt{color:rgb(var(--ctp-subtext1))!important}.chroma .lnt:focus,.chroma .lnt:target{color:rgb(var(--ctp-subtext0))!important}.chroma .line{white-space:break-spaces}.chroma .line.active,.chroma .line.active *{background:rgb(var(--ctp-surface0))!important}.bg,.chroma{color:#4c4f69;background-color:#eff1f5}.chroma .lntd:last-child{width:100%}.chroma .ln:target,.chroma .lnt:target{color:#bcc0cc;background-color:#eff1f5}.chroma .err{color:#d20f39}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{color:#bcc0cc}.chroma .ln,.chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8c8fa1}.chroma .line{display:flex}.chroma .k{color:#8839ef}.chroma .kc{color:#fe640b}.chroma .kd{color:#d20f39}.chroma .kn{color:#179299}.chroma .kp,.chroma .kr{color:#8839ef}.chroma .kt{color:#d20f39}.chroma .na{color:#1e66f5}.chroma .bp,.chroma .nb{color:#04a5e5}.chroma .nc,.chroma .no{color:#df8e1d}.chroma .nd{color:#1e66f5;font-weight:700}.chroma .ni{color:#179299}.chroma .ne{color:#fe640b}.chroma .fm,.chroma .nf{color:#1e66f5}.chroma .nl{color:#04a5e5}.chroma .nn,.chroma .py{color:#fe640b}.chroma .nt{color:#8839ef}.chroma .nv,.chroma .vc,.chroma .vg,.chroma .vi,.chroma .vm{color:#dc8a78}.chroma .s{color:#40a02b}.chroma .sa{color:#d20f39}.chroma .sb,.chroma .sc{color:#40a02b}.chroma .dl{color:#1e66f5}.chroma .sd{color:#9ca0b0}.chroma .s2{color:#40a02b}.chroma .se{color:#1e66f5}.chroma .sh{color:#9ca0b0}.chroma .si,.chroma .sx{color:#40a02b}.chroma .sr{color:#179299}.chroma .s1,.chroma .ss{color:#40a02b}.chroma .il,.chroma .m,.chroma .mb,.chroma .mf,.chroma .mh,.chroma .mi,.chroma .mo{color:#fe640b}.chroma .o,.chroma .ow{color:#04a5e5;font-weight:700}.chroma .c,.chroma .c1,.chroma .ch,.chroma .cm,.chroma .cp,.chroma .cpf,.chroma .cs{color:#9ca0b0;font-style:italic}.chroma .cpf{font-weight:700}.chroma .gd{color:#d20f39;background-color:#ccd0da}.chroma .ge{font-style:italic}.chroma .gr{color:#d20f39}.chroma .gh{color:#fe640b;font-weight:700}.chroma .gi{color:#40a02b;background-color:#ccd0da}.chroma .gs,.chroma .gu{font-weight:700}.chroma .gu{color:#fe640b}.chroma .gt{color:#d20f39}.chroma .gl{text-decoration:underline}@media (prefers-color-scheme:dark){.bg,.chroma{color:#cdd6f4;background-color:#1e1e2e}.chroma .lntd:last-child{width:100%}.chroma .ln:target,.chroma .lnt:target{color:#45475a;background-color:#1e1e2e}.chroma .err{color:#f38ba8}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{color:#45475a}.chroma .ln,.chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#7f849c}.chroma .line{display:flex}.chroma .k{color:#cba6f7}.chroma .kc{color:#fab387}.chroma .kd{color:#f38ba8}.chroma .kn{color:#94e2d5}.chroma .kp,.chroma .kr{color:#cba6f7}.chroma .kt{color:#f38ba8}.chroma .na{color:#89b4fa}.chroma .bp,.chroma .nb{color:#89dceb}.chroma .nc,.chroma .no{color:#f9e2af}.chroma .nd{color:#89b4fa;font-weight:700}.chroma .ni{color:#94e2d5}.chroma .ne{color:#fab387}.chroma .fm,.chroma .nf{color:#89b4fa}.chroma .nl{color:#89dceb}.chroma .nn,.chroma .py{color:#fab387}.chroma .nt{color:#cba6f7}.chroma .nv,.chroma .vc,.chroma .vg,.chroma .vi,.chroma .vm{color:#f5e0dc}.chroma .s{color:#a6e3a1}.chroma .sa{color:#f38ba8}.chroma .sb,.chroma .sc{color:#a6e3a1}.chroma .dl{color:#89b4fa}.chroma .sd{color:#6c7086}.chroma .s2{color:#a6e3a1}.chroma .se{color:#89b4fa}.chroma .sh{color:#6c7086}.chroma .si,.chroma .sx{color:#a6e3a1}.chroma .sr{color:#94e2d5}.chroma .s1,.chroma .ss{color:#a6e3a1}.chroma .il,.chroma .m,.chroma .mb,.chroma .mf,.chroma .mh,.chroma .mi,.chroma .mo{color:#fab387}.chroma .o,.chroma .ow{color:#89dceb;font-weight:700}.chroma .c,.chroma .c1,.chroma .ch,.chroma .cm,.chroma .cp,.chroma .cpf,.chroma .cs{color:#6c7086;font-style:italic}.chroma .cpf{font-weight:700}.chroma .gd{color:#f38ba8;background-color:#313244}.chroma .ge{font-style:italic}.chroma .gr{color:#f38ba8}.chroma .gh{color:#fab387;font-weight:700}.chroma .gi{color:#a6e3a1;background-color:#313244}.chroma .gs,.chroma .gu{font-weight:700}.chroma .gu{color:#fab387}.chroma .gt{color:#f38ba8}.chroma .gl{text-decoration:underline}.dark\\:mocha{--ctp-rosewater:245,224,220;--ctp-flamingo:242,205,205;--ctp-pink:245,194,231;--ctp-mauve:203,166,247;--ctp-red:243,139,168;--ctp-maroon:235,160,172;--ctp-peach:250,179,135;--ctp-yellow:249,226,175;--ctp-green:166,227,161;--ctp-teal:148,226,213;--ctp-sky:137,220,235;--ctp-sapphire:116,199,236;--ctp-blue:137,180,250;--ctp-lavender:180,190,254;--ctp-text:205,214,244;--ctp-subtext1:186,194,222;--ctp-subtext0:166,173,200;--ctp-overlay2:147,153,178;--ctp-overlay1:127,132,156;--ctp-overlay0:108,112,134;--ctp-surface2:88,91,112;--ctp-surface1:69,71,90;--ctp-surface0:49,50,68;--ctp-base:30,30,46;--ctp-mantle:24,24,37;--ctp-crust:17,17,27}}.hover\\:decoration-solid:hover{text-decoration-style:solid}@media (prefers-color-scheme:dark){.dark\\:bg-base\\/95{background-color:rgba(var(--ctp-base),.95)}.dark\\:text-lavender{--tw-text-opacity:1;color:rgba(var(--ctp-lavender),var(--tw-text-opacity))}.dark\\:decoration-lavender\\/50{text-decoration-color:rgba(var(--ctp-lavender),.5)}}@media (min-width:640px){.sm\\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}")) +} diff --git a/internal/http/git.go b/internal/http/git.go new file mode 100644 index 0000000..56d7e28 --- /dev/null +++ b/internal/http/git.go @@ -0,0 +1,50 @@ +package http + +import ( + "errors" + "net/http" + "path/filepath" + + "go.jolheiser.com/ugit/internal/git" + "go.jolheiser.com/ugit/internal/http/httperr" + + "github.com/go-chi/chi/v5" +) + +func (rh repoHandler) infoRefs(w http.ResponseWriter, r *http.Request) error { + if r.URL.Query().Get("service") != "git-upload-pack" { + return httperr.Status(errors.New("pushing isn't supported via HTTP(S), use SSH"), http.StatusBadRequest) + } + + w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") + rp := filepath.Join(rh.s.RepoDir, chi.URLParam(r, "repo")+".git") + repo, err := git.NewProtocol(rp) + if err != nil { + return httperr.Error(err) + } + if err := repo.HTTPInfoRefs(Session{ + w: w, + r: r, + }); err != nil { + return httperr.Error(err) + } + + return nil +} + +func (rh repoHandler) uploadPack(w http.ResponseWriter, r *http.Request) error { + w.Header().Set("content-type", "application/x-git-upload-pack-result") + rp := filepath.Join(rh.s.RepoDir, chi.URLParam(r, "repo")+".git") + repo, err := git.NewProtocol(rp) + if err != nil { + return httperr.Error(err) + } + if err := repo.HTTPUploadPack(Session{ + w: w, + r: r, + }); err != nil { + return httperr.Error(err) + } + + return nil +} diff --git a/internal/http/http.go b/internal/http/http.go new file mode 100644 index 0000000..d4d4067 --- /dev/null +++ b/internal/http/http.go @@ -0,0 +1,121 @@ +package http + +import ( + "fmt" + "net/http" + "net/url" + + "go.jolheiser.com/ugit/assets" + "go.jolheiser.com/ugit/internal/git" + "go.jolheiser.com/ugit/internal/html" + "go.jolheiser.com/ugit/internal/http/httperr" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +// Server is the container struct for the HTTP server +type Server struct { + port int + mux *chi.Mux +} + +// ListenAndServe simply wraps http.ListenAndServe to contain the functionality here +func (s Server) ListenAndServe() error { + return http.ListenAndServe(fmt.Sprintf("localhost:%d", s.port), s.mux) +} + +// Settings is the configuration for the HTTP server +type Settings struct { + Title string + Description string + CloneURL string + Port int + RepoDir string + Profile Profile +} + +// Profile is the index profile +type Profile struct { + Username string + Email string + Links []Link +} + +// Link is a profile link +type Link struct { + Name string + URL string +} + +func (s Settings) goGet(repo string) string { + u, _ := url.Parse(s.CloneURL) + return fmt.Sprintf(`%[1]s`, repo, u.Hostname(), s.CloneURL) +} + +// New returns a new HTTP server +func New(settings Settings) Server { + mux := chi.NewMux() + + mux.Use(middleware.Logger) + mux.Use(middleware.Recoverer) + + rh := repoHandler{s: settings} + mux.Route("/{repo}.git", func(r chi.Router) { + r.Get("/info/refs", httperr.Handler(rh.infoRefs)) + r.Post("/git-upload-pack", httperr.Handler(rh.uploadPack)) + }) + + mux.Route("/", func(r chi.Router) { + r.Get("/", httperr.Handler(rh.index)) + r.Route("/{repo}", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("go-get") { + repo := chi.URLParam(r, "repo") + w.Write([]byte(settings.goGet(repo))) + return + } + rh.repoTree("", "").ServeHTTP(w, r) + }) + r.Get("/tree/{ref}/*", func(w http.ResponseWriter, r *http.Request) { + rh.repoTree(chi.URLParam(r, "ref"), chi.URLParam(r, "*")).ServeHTTP(w, r) + }) + }) + }) + + mux.Route("/_", func(r chi.Router) { + r.Get("/favicon.svg", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Write(assets.LogoIcon) + }) + r.Get("/tailwind.css", html.TailwindHandler) + }) + + return Server{mux: mux, port: settings.Port} +} + +type repoHandler struct { + s Settings +} + +func (rh repoHandler) baseContext() html.BaseContext { + return html.BaseContext{ + Title: rh.s.Title, + Description: rh.s.Description, + } +} + +func (rh repoHandler) repoHeaderContext(repo *git.Repo, r *http.Request) html.RepoHeaderComponentContext { + return html.RepoHeaderComponentContext{ + Description: repo.Meta.Description, + Name: chi.URLParam(r, "repo"), + Ref: chi.URLParam(r, "ref"), + } +} + +// NoopLogger is a no-op logging middleware +func NoopLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) +} diff --git a/internal/http/httperr/httperr.go b/internal/http/httperr/httperr.go new file mode 100644 index 0000000..b6bcd1f --- /dev/null +++ b/internal/http/httperr/httperr.go @@ -0,0 +1,45 @@ +package httperr + +import ( + "errors" + "net/http" + + "github.com/charmbracelet/log" +) + +type httpError struct { + err error + status int +} + +func (h httpError) Error() string { + return h.err.Error() +} + +func (h httpError) Unwrap() error { + return h.err +} + +func Error(err error) httpError { + return Status(err, http.StatusInternalServerError) +} + +func Status(err error, status int) httpError { + return httpError{err: err, status: status} +} + +func Handler(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := fn(w, r); err != nil { + status := http.StatusInternalServerError + + var httpErr httpError + if errors.As(err, &httpErr) { + status = httpErr.status + } + + log.Error(err) + http.Error(w, http.StatusText(status), status) + } + } +} diff --git a/internal/http/index.go b/internal/http/index.go new file mode 100644 index 0000000..dfe761c --- /dev/null +++ b/internal/http/index.go @@ -0,0 +1,63 @@ +package http + +import ( + "net/http" + "os" + "sort" + "time" + + "go.jolheiser.com/ugit/internal/git" + "go.jolheiser.com/ugit/internal/html" + "go.jolheiser.com/ugit/internal/http/httperr" +) + +func (rh repoHandler) index(w http.ResponseWriter, r *http.Request) error { + repoPaths, err := os.ReadDir(rh.s.RepoDir) + if err != nil { + return httperr.Error(err) + } + + repos := make([]*git.Repo, 0, len(repoPaths)) + for _, repoName := range repoPaths { + repo, err := git.NewRepo(rh.s.RepoDir, repoName.Name()) + if err != nil { + return httperr.Error(err) + } + if !repo.Meta.Private { + repos = append(repos, repo) + } + } + sort.Slice(repos, func(i, j int) bool { + var when1, when2 time.Time + if c, err := repos[i].LastCommit(); err == nil { + when1 = c.Author.When + } + if c, err := repos[j].LastCommit(); err == nil { + when2 = c.Author.When + } + return when1.After(when2) + }) + + links := make([]html.IndexLink, 0, len(rh.s.Profile.Links)) + for _, link := range rh.s.Profile.Links { + links = append(links, html.IndexLink{ + Name: link.Name, + URL: link.URL, + }) + } + + if err := html.Index(html.IndexContext{ + BaseContext: rh.baseContext(), + Profile: html.IndexProfile{ + Username: rh.s.Profile.Username, + Email: rh.s.Profile.Email, + Links: links, + }, + CloneURL: rh.s.CloneURL, + Repos: repos, + }).Render(r.Context(), w); err != nil { + return httperr.Error(err) + } + + return nil +} diff --git a/internal/http/repo.go b/internal/http/repo.go new file mode 100644 index 0000000..bdf82a2 --- /dev/null +++ b/internal/http/repo.go @@ -0,0 +1,109 @@ +package http + +import ( + "bytes" + "errors" + "io/fs" + "mime" + "net/http" + "path/filepath" + + "go.jolheiser.com/ugit/internal/git" + "go.jolheiser.com/ugit/internal/html" + "go.jolheiser.com/ugit/internal/http/httperr" + + "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func (rh repoHandler) repoTree(ref, path string) http.HandlerFunc { + return httperr.Handler(func(w http.ResponseWriter, r *http.Request) error { + repoName := chi.URLParam(r, "repo") + repo, err := git.NewRepo(rh.s.RepoDir, repoName) + if err != nil { + httpErr := http.StatusInternalServerError + if errors.Is(err, fs.ErrNotExist) { + httpErr = http.StatusNotFound + } + return httperr.Status(err, httpErr) + } + if repo.Meta.Private { + return httperr.Status(errors.New("could not get git repo"), http.StatusNotFound) + } + + if ref == "" { + ref, err = repo.DefaultBranch() + if err != nil { + return httperr.Error(err) + } + } + + tree, err := repo.Dir(ref, path) + if err != nil { + if errors.Is(err, object.ErrDirectoryNotFound) { + return rh.repoFile(w, r, repo, ref, path) + } + return httperr.Error(err) + } + + readmeContent, err := html.Readme(repo, ref, path) + if err != nil { + return httperr.Error(err) + } + + var back string + if path != "" { + back = filepath.Dir(path) + } + if err := html.RepoTree(html.RepoTreeContext{ + Description: repo.Meta.Description, + BaseContext: rh.baseContext(), + RepoHeaderComponentContext: rh.repoHeaderContext(repo, r), + RepoTreeComponentContext: html.RepoTreeComponentContext{ + Repo: repoName, + Ref: ref, + Tree: tree, + Back: back, + }, + ReadmeComponentContext: html.ReadmeComponentContext{ + Markdown: readmeContent, + }, + }).Render(r.Context(), w); err != nil { + return httperr.Error(err) + } + + return nil + }) +} + +func (rh repoHandler) repoFile(w http.ResponseWriter, r *http.Request, repo *git.Repo, ref, path string) error { + content, err := repo.FileContent(ref, path) + if err != nil { + return httperr.Error(err) + } + + if r.URL.Query().Has("raw") { + if r.URL.Query().Has("pretty") { + ext := filepath.Ext(path) + w.Header().Set("Content-Type", mime.TypeByExtension(ext)) + } + w.Write([]byte(content)) + return nil + } + + var buf bytes.Buffer + if err := html.Code.Convert([]byte(content), filepath.Base(path), &buf); err != nil { + return httperr.Error(err) + } + + if err := html.RepoFile(html.RepoFileContext{ + BaseContext: rh.baseContext(), + RepoHeaderComponentContext: rh.repoHeaderContext(repo, r), + Code: buf.String(), + Path: path, + }).Render(r.Context(), w); err != nil { + return httperr.Error(err) + } + + return nil +} diff --git a/internal/http/session.go b/internal/http/session.go new file mode 100644 index 0000000..b427f08 --- /dev/null +++ b/internal/http/session.go @@ -0,0 +1,32 @@ +package http + +import ( + "context" + "net/http" +) + +// Session fulfills git.ReadWriteContexter for an HTTP request +type Session struct { + w http.ResponseWriter + r *http.Request +} + +// Read implements io.Reader +func (s Session) Read(p []byte) (n int, err error) { + return s.r.Body.Read(p) +} + +// Write implements io.Writer +func (s Session) Write(p []byte) (n int, err error) { + return s.w.Write(p) +} + +// Close implements io.Closer +func (s Session) Close() error { + return s.r.Body.Close() +} + +// Context implements git.ReadWriteContexter +func (s Session) Context() context.Context { + return s.r.Context() +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go index 4a494b6..31c49eb 100644 --- a/internal/ssh/ssh.go +++ b/internal/ssh/ssh.go @@ -3,20 +3,30 @@ package ssh import ( "fmt" + "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" - "github.com/charmbracelet/wish/git" "github.com/charmbracelet/wish/logging" ) -func New() (*ssh.Server, error) { +// Settings holds the configuration for the SSH server +type Settings struct { + AuthorizedKeys string + CloneURL string + Port int + HostKey string + RepoDir string +} + +// New creates a new SSH server. +func New(settings Settings) (*ssh.Server, error) { s, err := wish.NewServer( - wish.WithAuthorizedKeys(".ssh/authorized_keys"), - wish.WithAddress("localhost:8448"), - wish.WithHostKeyPath(".ssh/ugit_ed25519"), + wish.WithAuthorizedKeys(settings.AuthorizedKeys), + wish.WithAddress(fmt.Sprintf(":%d", settings.Port)), + wish.WithHostKeyPath(settings.HostKey), wish.WithMiddleware( - git.Middleware(".ugit", app{}), - logging.Middleware(), + Middleware(settings.RepoDir, settings.CloneURL, settings.Port, hooks{}), + logging.MiddlewareWithLogger(DefaultLogger), ), ) if err != nil { @@ -26,10 +36,16 @@ func New() (*ssh.Server, error) { return s, nil } -type app struct{} +type hooks struct{} -func (a app) AuthRepo(repo string, pk ssh.PublicKey) git.AccessLevel { - return git.ReadWriteAccess -} -func (a app) Push(_ string, _ ssh.PublicKey) {} -func (a app) Fetch(_ string, _ ssh.PublicKey) {} +func (a hooks) Push(_ string, _ ssh.PublicKey) {} +func (a hooks) Fetch(_ string, _ ssh.PublicKey) {} + +var ( + DefaultLogger logging.Logger = log.StandardLog() + NoopLogger logging.Logger = noopLogger{} +) + +type noopLogger struct{} + +func (n noopLogger) Printf(format string, v ...interface{}) {} diff --git a/internal/ssh/wish.go b/internal/ssh/wish.go new file mode 100644 index 0000000..89fd6ef --- /dev/null +++ b/internal/ssh/wish.go @@ -0,0 +1,164 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "go.jolheiser.com/ugit/internal/git" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" +) + +// ErrSystemMalfunction represents a general system error returned to clients. +var ErrSystemMalfunction = errors.New("something went wrong") + +// ErrInvalidRepo represents an attempt to access a non-existent repo. +var ErrInvalidRepo = errors.New("invalid repo") + +// Hooks is an interface that allows for custom authorization +// implementations and post push/fetch notifications. Prior to git access, +// AuthRepo will be called with the ssh.Session public key and the repo name. +// Implementers return the appropriate AccessLevel. +type Hooks interface { + Push(string, ssh.PublicKey) + Fetch(string, ssh.PublicKey) +} + +// Session wraps sn ssh.Session to implement git.ReadWriteContexter +type Session struct { + s ssh.Session +} + +// Read implements io.Reader +func (s Session) Read(p []byte) (n int, err error) { + return s.s.Read(p) +} + +// Write implements io.Writer +func (s Session) Write(p []byte) (n int, err error) { + return s.s.Write(p) +} + +// Close implements io.Closer +func (s Session) Close() error { + return nil +} + +// Context returns an interface context.Context +func (s Session) Context() context.Context { + return s.s.Context() +} + +// Middleware adds Git server functionality to the ssh.Server. Repos are stored +// in the specified repo directory. The provided Hooks implementation will be +// checked for access on a per repo basis for a ssh.Session public key. +// Hooks.Push and Hooks.Fetch will be called on successful completion of +// their commands. +func Middleware(repoDir string, cloneURL string, port int, gh Hooks) wish.Middleware { + return func(sh ssh.Handler) ssh.Handler { + return func(s ssh.Session) { + sess := Session{s: s} + cmd := s.Command() + + // Git operations + if len(cmd) == 2 { + gc := cmd[0] + // repo should be in the form of "repo.git" or "user/repo.git" + repo := strings.TrimSuffix(strings.TrimPrefix(cmd[1], "/"), "/") + repo = filepath.Clean(repo) + if n := strings.Count(repo, "/"); n > 1 { + Fatal(s, ErrInvalidRepo) + return + } + pk := s.PublicKey() + switch gc { + case "git-receive-pack": + if err := gitPack(sess, gc, repoDir, repo); err != nil { + Fatal(s, ErrSystemMalfunction) + } + gh.Push(repo, pk) + return + case "git-upload-archive", "git-upload-pack": + if err := gitPack(sess, gc, repoDir, repo); err != nil { + if errors.Is(err, ErrInvalidRepo) { + Fatal(s, ErrInvalidRepo) + } + log.Error("unknown git error", "error", err) + Fatal(s, ErrSystemMalfunction) + } + gh.Fetch(repo, pk) + return + } + } + + // Repo list + if len(cmd) == 0 { + des, err := os.ReadDir(repoDir) + if err != nil && err != fs.ErrNotExist { + log.Error("invalid repository", "error", err) + } + for _, de := range des { + fmt.Fprintln(s, de.Name()) + fmt.Fprintf(s, "\tgit clone %s/%s\n", cloneURL, de.Name()) + } + } + sh(s) + } + } +} + +func gitPack(s Session, gitCmd string, repoDir string, repoName string) error { + rp := filepath.Join(repoDir, repoName) + protocol, err := git.NewProtocol(rp) + if err != nil { + return err + } + switch gitCmd { + case "git-upload-pack": + exists, err := git.PathExists(rp) + if !exists { + return ErrInvalidRepo + } + if err != nil { + return err + } + return protocol.SSHUploadPack(s) + case "git-receive-pack": + err := git.EnsureRepo(repoDir, repoName) + if err != nil { + return err + } + repo, err := git.NewRepo(repoDir, repoName) + if err != nil { + return err + } + err = protocol.SSHReceivePack(s, repo) + if err != nil { + return err + } + _, err = repo.DefaultBranch() + if err != nil { + return err + } + // Needed for git dumb http server + return git.UpdateServerInfo(rp) + default: + return fmt.Errorf("unknown git command: %s", gitCmd) + } +} + +// Fatal prints to the session's STDOUT as a git response and exit 1. +func Fatal(s ssh.Session, v ...interface{}) { + msg := fmt.Sprint(v...) + // hex length includes 4 byte length prefix and ending newline + pktLine := fmt.Sprintf("%04x%s\n", len(msg)+5, msg) + _, _ = wish.WriteString(s, pktLine) + s.Exit(1) // nolint: errcheck +}