Compare commits

..

4 Commits

Author SHA1 Message Date
jolheiser 86aa09929f
a few more unit tests 2025-06-04 16:13:46 -05:00
jolheiser b8ca3fc4b8
fix tags 2025-06-04 16:06:14 -05:00
jolheiser ebe2dc4603
add last commit to index 2025-06-04 15:09:24 -05:00
jolheiser ea40aa746e
permalink 2025-06-04 13:47:09 -05:00
13 changed files with 506 additions and 40 deletions

View File

@ -3,8 +3,10 @@ package git_test
import (
"path/filepath"
"testing"
"time"
"github.com/alecthomas/assert/v2"
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
"go.jolheiser.com/ugit/internal/git"
)
@ -43,3 +45,232 @@ func TestRepo(t *testing.T) {
assert.NoError(t, err, "should not error when getting existing repo")
assert.False(t, repo.Meta.Private, "repo should be public after saving meta")
}
func TestPathExists(t *testing.T) {
tmp := t.TempDir()
exists, err := git.PathExists(tmp)
assert.NoError(t, err)
assert.True(t, exists)
doesNotExist := filepath.Join(tmp, "does-not-exist")
exists, err = git.PathExists(doesNotExist)
assert.NoError(t, err)
assert.False(t, exists)
}
func TestRepoMetaUpdate(t *testing.T) {
original := git.RepoMeta{
Description: "Original description",
Private: true,
Tags: git.TagSet{"tag1": struct{}{}, "tag2": struct{}{}},
}
update := git.RepoMeta{
Description: "Updated description",
Private: false,
Tags: git.TagSet{"tag3": struct{}{}},
}
err := original.Update(update)
assert.NoError(t, err)
assert.Equal(t, "Updated description", original.Description)
assert.False(t, original.Private)
assert.Equal(t, []string{"tag1", "tag2", "tag3"}, original.Tags.Slice())
}
func TestFileInfoName(t *testing.T) {
testCases := []struct {
path string
expected string
}{
{path: "file.txt", expected: "file.txt"},
{path: "dir/file.txt", expected: "file.txt"},
{path: "nested/path/to/file.go", expected: "file.go"},
{path: "README.md", expected: "README.md"},
}
for _, tc := range testCases {
t.Run(tc.path, func(t *testing.T) {
fi := git.FileInfo{Path: tc.path}
assert.Equal(t, tc.expected, fi.Name())
})
}
}
func TestCommitSummaryAndDetails(t *testing.T) {
testCases := []struct {
message string
expectedSummary string
expectedDetails string
}{
{
message: "Simple commit message",
expectedSummary: "Simple commit message",
expectedDetails: "",
},
{
message: "Add feature X\n\nThis commit adds feature X\nWith multiple details\nAcross multiple lines",
expectedSummary: "Add feature X",
expectedDetails: "\nThis commit adds feature X\nWith multiple details\nAcross multiple lines",
},
{
message: "Fix bug\n\nDetailed explanation",
expectedSummary: "Fix bug",
expectedDetails: "\nDetailed explanation",
},
}
for _, tc := range testCases {
t.Run(tc.message, func(t *testing.T) {
commit := git.Commit{
SHA: "abcdef1234567890",
Message: tc.message,
Signature: "",
Author: "Test User",
Email: "test@example.com",
When: time.Now(),
}
assert.Equal(t, tc.expectedSummary, commit.Summary())
assert.Equal(t, tc.expectedDetails, commit.Details())
})
}
}
func TestCommitShort(t *testing.T) {
commit := git.Commit{
SHA: "abcdef1234567890abcdef1234567890",
}
assert.Equal(t, "abcdef12", commit.Short())
}
func TestCommitFilePath(t *testing.T) {
testCases := []struct {
name string
fromPath string
toPath string
expected string
}{
{
name: "to path preferred",
fromPath: "old/path.txt",
toPath: "new/path.txt",
expected: "new/path.txt",
},
{
name: "fallback to from path",
fromPath: "deleted/file.txt",
toPath: "",
expected: "deleted/file.txt",
},
{
name: "both paths empty",
fromPath: "",
toPath: "",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cf := git.CommitFile{
From: git.CommitFileEntry{Path: tc.fromPath},
To: git.CommitFileEntry{Path: tc.toPath},
}
assert.Equal(t, tc.expected, cf.Path())
})
}
}
func TestRepoName(t *testing.T) {
tmp := t.TempDir()
repoName := "testrepo"
err := git.EnsureRepo(tmp, repoName+".git")
assert.NoError(t, err)
repo, err := git.NewRepo(tmp, repoName)
assert.NoError(t, err)
assert.Equal(t, repoName, repo.Name())
repoName2 := "test-repo-with-hyphens"
err = git.EnsureRepo(tmp, repoName2+".git")
assert.NoError(t, err)
repo2, err := git.NewRepo(tmp, repoName2)
assert.NoError(t, err)
assert.Equal(t, repoName2, repo2.Name())
}
func TestHandlePushOptions(t *testing.T) {
tmp := t.TempDir()
err := git.EnsureRepo(tmp, "test.git")
assert.NoError(t, err)
repo, err := git.NewRepo(tmp, "test")
assert.NoError(t, err)
opts := []*packp.Option{
{Key: "description", Value: "New description"},
}
err = git.HandlePushOptions(repo, opts)
assert.NoError(t, err)
assert.Equal(t, "New description", repo.Meta.Description)
opts = []*packp.Option{
{Key: "private", Value: "false"},
}
err = git.HandlePushOptions(repo, opts)
assert.NoError(t, err)
assert.False(t, repo.Meta.Private)
repo.Meta.Private = true
opts = []*packp.Option{
{Key: "private", Value: "invalid"},
}
err = git.HandlePushOptions(repo, opts)
assert.NoError(t, err)
assert.True(t, repo.Meta.Private)
opts = []*packp.Option{
{Key: "tags", Value: "tag1,tag2"},
}
err = git.HandlePushOptions(repo, opts)
assert.NoError(t, err)
opts = []*packp.Option{
{Key: "description", Value: "Combined update"},
{Key: "private", Value: "true"},
}
err = git.HandlePushOptions(repo, opts)
assert.NoError(t, err)
assert.Equal(t, "Combined update", repo.Meta.Description)
assert.True(t, repo.Meta.Private)
}
func TestRepoPath(t *testing.T) {
tmp := t.TempDir()
err := git.EnsureRepo(tmp, "test.git")
assert.NoError(t, err)
repo, err := git.NewRepo(tmp, "test")
assert.NoError(t, err)
expected := filepath.Join(tmp, "test.git")
assert.Equal(t, expected, repo.Path())
}
func TestEnsureJSONFile(t *testing.T) {
tmp := t.TempDir()
err := git.EnsureRepo(tmp, "test.git")
assert.NoError(t, err)
repo, err := git.NewRepo(tmp, "test")
assert.NoError(t, err)
assert.True(t, repo.Meta.Private, "default repo should be private")
assert.Equal(t, "", repo.Meta.Description, "default description should be empty")
assert.Equal(t, 0, len(repo.Meta.Tags), "default tags should be empty")
}

View File

@ -7,13 +7,60 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
)
// RepoMeta is the meta information a Repo can have
type RepoMeta struct {
Description string `json:"description"`
Private bool `json:"private"`
Tags []string `json:"tags"`
Description string `json:"description"`
Private bool `json:"private"`
Tags TagSet `json:"tags"`
}
// TagSet is a Set of tags
type TagSet map[string]struct{}
// Add adds a tag to the set
func (t TagSet) Add(tag string) {
t[tag] = struct{}{}
}
// Remove removes a tag from the set
func (t TagSet) Remove(tag string) {
delete(t, tag)
}
// Contains checks if a tag is in the set
func (t TagSet) Contains(tag string) bool {
_, ok := t[tag]
return ok
}
// Slice returns the set as a (sorted) slice
func (t TagSet) Slice() []string {
s := make([]string, 0, len(t))
for k := range t {
s = append(s, k)
}
slices.Sort(s)
return s
}
// MarshalJSON implements [json.Marshaler]
func (t TagSet) MarshalJSON() ([]byte, error) {
return json.Marshal(t.Slice())
}
// UnmarshalJSON implements [json.Unmarshaler]
func (t *TagSet) UnmarshalJSON(b []byte) error {
var s []string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
for _, ss := range s {
t.Add(ss)
}
return nil
}
// Update updates meta given another RepoMeta

View File

@ -0,0 +1,53 @@
package git
import (
"encoding/json"
"testing"
"github.com/alecthomas/assert/v2"
)
func TestTagSet(t *testing.T) {
set := make(TagSet)
assert.Equal(t, 0, len(set))
assert.Equal(t, 0, len(set.Slice()))
set.Add("foo")
assert.Equal(t, 1, len(set))
assert.Equal(t, 1, len(set.Slice()))
assert.True(t, set.Contains("foo"))
set.Add("bar")
assert.Equal(t, 2, len(set))
assert.Equal(t, 2, len(set.Slice()))
assert.True(t, set.Contains("foo"))
assert.True(t, set.Contains("bar"))
set.Add("bar")
assert.Equal(t, 2, len(set))
assert.Equal(t, 2, len(set.Slice()))
assert.True(t, set.Contains("foo"))
assert.True(t, set.Contains("bar"))
set.Remove("foo")
assert.Equal(t, 1, len(set))
assert.Equal(t, 1, len(set.Slice()))
assert.False(t, set.Contains("foo"))
assert.True(t, set.Contains("bar"))
set.Add("foo")
set.Add("baz")
j, err := json.Marshal(set)
assert.NoError(t, err)
assert.Equal(t, `["bar","baz","foo"]`, string(j))
set = make(TagSet)
b := []byte(`["foo","bar","baz"]`)
err = json.Unmarshal(b, &set)
assert.NoError(t, err)
assert.Equal(t, 3, len(set))
assert.Equal(t, 3, len(set.Slice()))
assert.True(t, set.Contains("foo"))
assert.True(t, set.Contains("bar"))
assert.True(t, set.Contains("baz"))
}

View File

@ -58,15 +58,11 @@ func HandlePushOptions(repo *Repo, opts []*packp.Option) error {
remove = true
tagValue = strings.TrimPrefix(tagValue, "-")
}
for idx, tag := range repo.Meta.Tags {
if strings.EqualFold(tag, tagValue) {
if remove {
repo.Meta.Tags = append(repo.Meta.Tags[:idx], repo.Meta.Tags[idx+1:]...)
} else {
repo.Meta.Tags = append(repo.Meta.Tags, strings.ToLower(tagValue))
}
break
}
tagValue = strings.ToLower(tagValue)
if remove {
repo.Meta.Tags.Remove(tagValue)
} else {
repo.Meta.Tags.Add(tagValue)
}
}
}

View File

@ -57,6 +57,9 @@ func NewRepo(dir, name string) (*Repo, error) {
if err := json.NewDecoder(fi).Decode(&r.Meta); err != nil {
return nil, err
}
if r.Meta.Tags == nil {
r.Meta.Tags = make(TagSet)
}
return r, nil
}

View File

@ -1,6 +1,8 @@
package html
import (
"fmt"
"github.com/dustin/go-humanize"
"go.jolheiser.com/ugit/assets"
"go.jolheiser.com/ugit/internal/git"
@ -25,7 +27,7 @@ type IndexLink struct {
URL string
}
func lastCommit(repo *git.Repo, human bool) string {
func lastCommitTime(repo *git.Repo, human bool) string {
c, err := repo.LastCommit()
if err != nil {
return ""
@ -36,6 +38,14 @@ func lastCommit(repo *git.Repo, human bool) string {
return c.When.Format("01/02/2006 03:04:05 PM")
}
func lastCommit(repo *git.Repo) *git.Commit {
c, err := repo.LastCommit()
if err != nil {
return nil
}
return &c
}
func IndexTemplate(ic IndexContext) Node {
return base(ic.BaseContext, []Node{
Header(
@ -65,19 +75,28 @@ func IndexTemplate(ic IndexContext) Node {
)
}),
),
Div(Class("grid sm:grid-cols-8 gap-2 mt-5"),
Div(Class("grid sm:grid-cols-10 gap-2 mt-5"),
Map(ic.Repos, func(repo *git.Repo) Node {
commit := lastCommit(repo)
return Group([]Node{
Div(Class("sm:col-span-2 text-blue dark:text-lavender"),
A(Class("underline decoration-blue/50 dark:decoration-lavender/50 decoration-dashed hover:decoration-solid"), Href("/"+repo.Name()), Text(repo.Name())),
),
Div(Class("sm:col-span-4 text-subtext0"), Text(repo.Meta.Description)),
Div(Class("sm:col-span-3 text-subtext0"), Text(repo.Meta.Description)),
Div(Class("sm:col-span-3 text-subtext0"),
If(commit != nil,
Div(Title(commit.Message),
A(Class("underline text-blue dark:text-lavender decoration-blue/50 dark:decoration-lavender/50 decoration-dashed hover:decoration-solid"), Href(fmt.Sprintf("/%s/commit/%s", repo.Name(), commit.SHA)), Text(commit.Short())),
Text(": "+commit.Summary()),
),
),
),
Div(Class("sm:col-span-1 text-subtext0"),
Map(repo.Meta.Tags, func(tag string) Node {
Map(repo.Meta.Tags.Slice(), func(tag string) Node {
return A(Class("rounded border-rosewater border-solid border pb-0.5 px-1 mr-1 mb-1 inline-block"), Href("?tag="+tag), Text(tag))
}),
),
Div(Class("sm:col-span-1 text-text/80 mb-4 sm:mb-0"), Title(lastCommit(repo, false)), Text(lastCommit(repo, true))),
Div(Class("sm:col-span-1 text-text/80 mb-4 sm:mb-0"), Title(lastCommitTime(repo, false)), Text(lastCommitTime(repo, true))),
})
}),
),

View File

@ -21,6 +21,7 @@ type RepoFileContext struct {
var repoFileJS string
func RepoFileTemplate(rfc RepoFileContext) Node {
permalink := fmt.Sprintf("/%s/tree/%s/%s", rfc.RepoBreadcrumbComponentContext.Repo, rfc.Commit, rfc.Path)
return base(rfc.BaseContext, []Node{
repoHeaderComponent(rfc.RepoHeaderComponentContext),
Div(Class("mt-2 text-text"),
@ -28,7 +29,7 @@ func RepoFileTemplate(rfc RepoFileContext) Node {
Text(" - "),
A(Class("text-text underline decoration-text/50 decoration-dashed hover:decoration-solid"), Href("?raw"), Text("raw")),
Text(" - "),
A(Class("text-text underline decoration-text/50 decoration-dashed hover:decoration-solid"), ID("permalink"), Href(fmt.Sprintf("/%s/tree/%s/%s", rfc.RepoBreadcrumbComponentContext.Repo, rfc.Commit, rfc.Path)), Text("permalink")),
A(Class("text-text underline decoration-text/50 decoration-dashed hover:decoration-solid"), ID("permalink"), Data("permalink", permalink), Href(permalink), Text("permalink")),
Div(Class("code relative"),
Raw(rfc.Code),
Button(ID("copy"), Class("absolute top-0 right-0 rounded bg-base hover:bg-surface0")),

View File

@ -2,7 +2,7 @@ const lineRe = /#L(\d+)(?:-L(\d+))?/g
const $lineLines = document.querySelectorAll(".chroma .lntable .lnt");
const $codeLines = document.querySelectorAll(".chroma .lntable .line");
const $copyButton = document.getElementById('copy');
const $permalinkButton = document.getElementById('permalink');
const $permalink = document.getElementById('permalink');
const $copyIcon = "📋";
const $copiedIcon = "✅";
let $code = ""
@ -14,9 +14,12 @@ if (0 in results) {
start = results[0][1] !== undefined ? parseInt(results[0][1]) : 0;
end = results[0][2] !== undefined ? parseInt(results[0][2]) : 0;
}
if (start != 0) {
if (start !== 0) {
deactivateLines();
activateLines(start, end);
let anchor = `#${start}`;
if (end !== 0) anchor += `-${end}`;
if (anchor !== "") $permalink.href = $permalink.dataset.permalink + anchor;
$lineLines[start - 1].scrollIntoView(true);
}
for (let line of $lineLines) {
@ -28,13 +31,17 @@ for (let line of $lineLines) {
if (event.shiftKey) {
end = n;
anchor = `#L${start}-L${end}`;
} else if (start === n) {
start = 0;
end = 0;
} else {
start = n;
end = 0;
anchor = `#L${start}`;
}
history.replaceState(null, null, anchor);
activateLines(start, end);
history.replaceState(null, null, window.location.pathname + anchor);
$permalink.href = $permalink.dataset.permalink + anchor;
if (start !== 0) activateLines(start, end);
});
}
if (navigator.clipboard && navigator.clipboard.writeText) {
@ -49,18 +56,6 @@ $copyButton.addEventListener("click", () => {
}, 1000);
});
$permalinkButton.addEventListener("click", (event) => {
event.preventDefault();
const url = $permalinkButton.getAttribute("href");
navigator.clipboard.writeText(window.location.origin + url + location.hash);
const originalText = $permalinkButton.innerText;
$permalinkButton.innerText = "copied!";
setTimeout(() => {
$permalinkButton.innerText = originalText;
}, 1000);
});
function activateLines(start, end) {
if (end < start) end = start;
for (let idx = start - 1; idx < end; idx++) {

File diff suppressed because one or more lines are too long

View File

@ -126,7 +126,7 @@ func (rh repoHandler) repoHeaderContext(repo *git.Repo, r *http.Request) html.Re
Name: chi.URLParam(r, "repo"),
Ref: ref,
CloneURL: rh.s.CloneURL,
Tags: repo.Meta.Tags,
Tags: repo.Meta.Tags.Slice(),
}
}

View File

@ -0,0 +1,122 @@
package httperr_test
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/alecthomas/assert/v2"
"go.jolheiser.com/ugit/internal/http/httperr"
)
func successHandler(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusOK)
return nil
}
func errorHandler(w http.ResponseWriter, r *http.Request) error {
return errors.New("test error")
}
func statusErrorHandler(status int) func(w http.ResponseWriter, r *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
return httperr.Status(errors.New("test error"), status)
}
}
func TestHandler_Success(t *testing.T) {
handler := httperr.Handler(successHandler)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
}
func TestHandler_Error(t *testing.T) {
handler := httperr.Handler(errorHandler)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
}
func TestHandler_StatusError(t *testing.T) {
testCases := []struct {
name string
status int
expectedStatus int
}{
{
name: "not found",
status: http.StatusNotFound,
expectedStatus: http.StatusNotFound,
},
{
name: "bad request",
status: http.StatusBadRequest,
expectedStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
status: http.StatusUnauthorized,
expectedStatus: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
handler := httperr.Handler(statusErrorHandler(tc.status))
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, tc.expectedStatus, recorder.Code)
})
}
}
type unwrapper interface {
Unwrap() error
}
func TestError(t *testing.T) {
originalErr := errors.New("original error")
httpErr := httperr.Error(originalErr)
assert.Equal(t, originalErr.Error(), httpErr.Error())
unwrapper, ok := any(httpErr).(unwrapper)
assert.True(t, ok)
assert.Equal(t, originalErr, unwrapper.Unwrap())
}
func TestStatus(t *testing.T) {
originalErr := errors.New("original error")
httpErr := httperr.Status(originalErr, http.StatusNotFound)
assert.Equal(t, originalErr.Error(), httpErr.Error())
unwrapper, ok := any(httpErr).(unwrapper)
assert.True(t, ok)
assert.Equal(t, originalErr, unwrapper.Unwrap())
handler := httperr.Handler(func(w http.ResponseWriter, r *http.Request) error {
return httpErr
})
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}

View File

@ -3,7 +3,6 @@ package http
import (
"net/http"
"os"
"slices"
"sort"
"strings"
"time"
@ -34,10 +33,10 @@ func (rh repoHandler) index(w http.ResponseWriter, r *http.Request) error {
if !rh.s.ShowPrivate {
continue
}
repo.Meta.Tags = append(repo.Meta.Tags, "private")
repo.Meta.Tags.Add("private")
}
if tagFilter != "" && !slices.Contains(repo.Meta.Tags, strings.ToLower(tagFilter)) {
if tagFilter != "" && !repo.Meta.Tags.Contains(strings.ToLower(tagFilter)) {
continue
}
repos = append(repos, repo)

View File

@ -30,7 +30,7 @@ func (rh repoHandler) repoMiddleware(next http.Handler) http.Handler {
if !rh.s.ShowPrivate {
return httperr.Status(errors.New("could not get git repo"), http.StatusNotFound)
}
repo.Meta.Tags = append(repo.Meta.Tags, "private")
repo.Meta.Tags.Add("private")
}
r = r.WithContext(context.WithValue(r.Context(), repoCtxKey, repo))
next.ServeHTTP(w, r)