.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 @@
+
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 @@
+
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 @@
+
+
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 }
+
+
+
+
+
+
+
+ { 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_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_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.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
+}