From 6cdf75c5a9e1f660aa2a86938be680c5db07ffd2 Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Sat, 21 Feb 2026 11:33:40 +0800 Subject: refstore: Add ref shortening --- refstore/chain/chain.go | 24 ++++++++++++++ refstore/loose/loose_test.go | 32 +++++++++++++++++++ refstore/loose/shorten.go | 29 +++++++++++++++++ refstore/refstore.go | 6 ++++ refstore/shorten.go | 74 ++++++++++++++++++++++++++++++++++++++++++++ refstore/shorten_test.go | 68 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 refstore/loose/shorten.go create mode 100644 refstore/shorten.go create mode 100644 refstore/shorten_test.go (limited to 'refstore') diff --git a/refstore/chain/chain.go b/refstore/chain/chain.go index b84aac55..0a78dc94 100644 --- a/refstore/chain/chain.go +++ b/refstore/chain/chain.go @@ -102,6 +102,30 @@ func (chain *Chain) List(pattern string) ([]ref.Ref, error) { return refs, nil } +// Shorten shortens a full reference name using the chain-visible namespace. +func (chain *Chain) Shorten(name string) (string, error) { + refs, err := chain.List("") + if err != nil { + return "", err + } + names := make([]string, 0, len(refs)) + found := false + for _, entry := range refs { + if entry == nil { + continue + } + full := entry.Name() + names = append(names, full) + if full == name { + found = true + } + } + if !found { + return "", refstore.ErrReferenceNotFound + } + return refstore.ShortenName(name, names), nil +} + // Close closes all backends and joins close errors. func (chain *Chain) Close() error { var errs []error diff --git a/refstore/loose/loose_test.go b/refstore/loose/loose_test.go index b56e40ac..63898721 100644 --- a/refstore/loose/loose_test.go +++ b/refstore/loose/loose_test.go @@ -147,3 +147,35 @@ func TestLooseMalformedDetachedRef(t *testing.T) { } }) } + +func TestLooseShorten(t *testing.T) { + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + testRepo := testgit.NewBareRepo(t, algo) + _, _, commitID := testRepo.MakeCommit(t, "shorten refs commit") + testRepo.UpdateRef(t, "refs/heads/main", commitID) + testRepo.UpdateRef(t, "refs/tags/main", commitID) + testRepo.UpdateRef(t, "refs/remotes/origin/main", commitID) + + store := openLooseStore(t, testRepo.Dir(), algo) + + shortHead, err := store.Shorten("refs/heads/main") + if err != nil { + t.Fatalf("Shorten(head): %v", err) + } + if shortHead != "heads/main" { + t.Fatalf("Shorten(refs/heads/main) = %q, want %q", shortHead, "heads/main") + } + + shortRemote, err := store.Shorten("refs/remotes/origin/main") + if err != nil { + t.Fatalf("Shorten(remote): %v", err) + } + if shortRemote != "origin/main" { + t.Fatalf("Shorten(remote) = %q, want %q", shortRemote, "origin/main") + } + + if _, err := store.Shorten("refs/heads/does-not-exist"); !errors.Is(err, refstore.ErrReferenceNotFound) { + t.Fatalf("Shorten(not-found) error = %v", err) + } + }) +} diff --git a/refstore/loose/shorten.go b/refstore/loose/shorten.go new file mode 100644 index 00000000..17a60def --- /dev/null +++ b/refstore/loose/shorten.go @@ -0,0 +1,29 @@ +package loose + +import ( + "codeberg.org/lindenii/furgit/refstore" +) + +// Shorten returns the shortest unambiguous shorthand for a loose ref name. +func (store *Store) Shorten(name string) (string, error) { + refs, err := store.List("") + if err != nil { + return "", err + } + names := make([]string, 0, len(refs)) + found := false + for _, entry := range refs { + if entry == nil { + continue + } + full := entry.Name() + names = append(names, full) + if full == name { + found = true + } + } + if !found { + return "", refstore.ErrReferenceNotFound + } + return refstore.ShortenName(name, names), nil +} diff --git a/refstore/refstore.go b/refstore/refstore.go index a3c9f201..5653d4ca 100644 --- a/refstore/refstore.go +++ b/refstore/refstore.go @@ -29,6 +29,12 @@ type Store interface { // // The exact pattern language is backend-defined. List(pattern string) ([]ref.Ref, error) + // Shorten returns the shortest unambiguous shorthand for a full + // reference name within this store's visible namespace. + // + // If name does not exist in this store, implementations should return + // ErrReferenceNotFound. + Shorten(name string) (string, error) // Close releases resources associated with the store. Close() error } diff --git a/refstore/shorten.go b/refstore/shorten.go new file mode 100644 index 00000000..26fa82c0 --- /dev/null +++ b/refstore/shorten.go @@ -0,0 +1,74 @@ +package refstore + +import "strings" + +type shortenRule struct { + prefix string + suffix string +} + +var shortenRules = [...]shortenRule{ + {prefix: "", suffix: ""}, + {prefix: "refs/", suffix: ""}, + {prefix: "refs/tags/", suffix: ""}, + {prefix: "refs/heads/", suffix: ""}, + {prefix: "refs/remotes/", suffix: ""}, + {prefix: "refs/remotes/", suffix: "/HEAD"}, +} + +func (rule shortenRule) match(name string) (string, bool) { + if !strings.HasPrefix(name, rule.prefix) { + return "", false + } + if !strings.HasSuffix(name, rule.suffix) { + return "", false + } + short := strings.TrimPrefix(name, rule.prefix) + short = strings.TrimSuffix(short, rule.suffix) + if short == "" { + return "", false + } + if rule.prefix+short+rule.suffix != name { + return "", false + } + return short, true +} + +func (rule shortenRule) render(short string) string { + return rule.prefix + short + rule.suffix +} + +// ShortenName returns the shortest unambiguous shorthand for name among all. +// +// all must contain full reference names visible to the shortening scope. +func ShortenName(name string, all []string) string { + names := make(map[string]struct{}, len(all)) + for _, full := range all { + if full == "" { + continue + } + names[full] = struct{}{} + } + + for i := len(shortenRules) - 1; i > 0; i-- { + short, ok := shortenRules[i].match(name) + if !ok { + continue + } + ambiguous := false + for j := range shortenRules { + if j == i { + continue + } + full := shortenRules[j].render(short) + if _, found := names[full]; found { + ambiguous = true + break + } + } + if !ambiguous { + return short + } + } + return name +} diff --git a/refstore/shorten_test.go b/refstore/shorten_test.go new file mode 100644 index 00000000..1975ab3f --- /dev/null +++ b/refstore/shorten_test.go @@ -0,0 +1,68 @@ +package refstore_test + +import ( + "testing" + + "codeberg.org/lindenii/furgit/refstore" +) + +func TestShortenName(t *testing.T) { + t.Parallel() + + t.Run("simple", func(t *testing.T) { + got := refstore.ShortenName("refs/heads/main", []string{"refs/heads/main"}) + if got != "main" { + t.Fatalf("ShortenName simple = %q, want %q", got, "main") + } + }) + + t.Run("ambiguous with tags", func(t *testing.T) { + got := refstore.ShortenName( + "refs/heads/main", + []string{ + "refs/heads/main", + "refs/tags/main", + }, + ) + if got != "heads/main" { + t.Fatalf("ShortenName tags ambiguity = %q, want %q", got, "heads/main") + } + }) + + t.Run("strict remote head ambiguity", func(t *testing.T) { + // In strict mode, refs/remotes/%s/HEAD blocks shortening to "%s". + got := refstore.ShortenName( + "refs/heads/main", + []string{ + "refs/heads/main", + "refs/remotes/main/HEAD", + }, + ) + if got != "heads/main" { + t.Fatalf("ShortenName strict ambiguity = %q, want %q", got, "heads/main") + } + }) + + t.Run("deep fallback still shortens", func(t *testing.T) { + // refs/remotes/origin/main conflicts with refs/heads/origin/main for + // "origin/main", so it should fall back to "remotes/origin/main". + got := refstore.ShortenName( + "refs/remotes/origin/main", + []string{ + "refs/remotes/origin/main", + "refs/heads/origin/main", + }, + ) + if got != "remotes/origin/main" { + t.Fatalf("ShortenName deep fallback = %q, want %q", got, "remotes/origin/main") + } + }) + + t.Run("refs-prefix fallback", func(t *testing.T) { + name := "refs/notes/review/topic" + got := refstore.ShortenName(name, []string{name}) + if got != "notes/review/topic" { + t.Fatalf("ShortenName refs-prefix fallback = %q, want %q", got, "notes/review/topic") + } + }) +} -- cgit v1.3.1-10-gc9f91