feat(git): more commands, tests, add Registry conflict between tests

This commit is contained in:
verse91
2026-05-10 00:03:19 +07:00
parent c76541258a
commit d74c959be4
3 changed files with 305 additions and 59 deletions
+5
View File
@@ -44,3 +44,8 @@ var Registry = map[string]*Spec{}
func Register(s *Spec) { func Register(s *Spec) {
Registry[s.Name] = s Registry[s.Name] = s
} }
// ResetRegistry clears all registered specs — use in tests only
func ResetRegistry() {
Registry = make(map[string]*Spec)
}
+139 -54
View File
@@ -13,40 +13,9 @@ func GitRemoteGenerator(tokens []string, prefix string, partial string) []core.S
return getGitResults(prefix, "remote") return getGitResults(prefix, "remote")
} }
// GitBranchGenerator suggests git branches // GitStashGenerator suggests git stashes
func GitBranchGenerator(tokens []string, prefix string, partial string) []core.Suggestion { func GitStashGenerator(tokens []string, prefix string, partial string) []core.Suggestion {
// check if we are in "create" mode (-b or -B) return getGitResults(prefix, "stash", "list", "--format=%gd: %gs")
isCreateMode := false
argCount := 0
for _, t := range tokens {
if t == "-b" || t == "-B" {
isCreateMode = true
}
// count non-flag, non-command tokens to see where we are
if t != "git" && t != "checkout" && t != "switch" && !strings.HasPrefix(t, "-") {
argCount++
}
}
// if in create mode and this is the first argument after flags,
// we shouldn't suggest existing branches as the new branch name
if isCreateMode && argCount == 0 {
return nil
}
return getGitResults(prefix, "branch", "--format=%(refname:short)")
}
// GitPushPullGenerator suggests remotes for the first arg, and branches for the second
func GitPushPullGenerator(tokens []string, prefix string, partial string) []core.Suggestion {
// tokens are like ["git", "push"] or ["git", "push", "origin"]
if len(tokens) == 2 {
return GitRemoteGenerator(tokens, prefix, partial)
}
if len(tokens) == 3 {
return GitBranchGenerator(tokens, prefix, partial)
}
return nil
} }
func getGitResults(prefix string, args ...string) []core.Suggestion { func getGitResults(prefix string, args ...string) []core.Suggestion {
@@ -58,6 +27,17 @@ func getGitResults(prefix string, args ...string) []core.Suggestion {
return nil return nil
} }
activeBranch := ""
switch args[0] {
case "branch":
// Try to find the current active branch to filter it out later
activeCmd := exec.CommandContext(context.Background(), "git", "rev-parse", "--abbrev-ref", "HEAD")
activeCmd.Dir = cwd
if activeOut, err := activeCmd.Output(); err == nil {
activeBranch = strings.TrimSpace(string(activeOut))
}
}
lines := strings.Split(string(out), "\n") lines := strings.Split(string(out), "\n")
var results []core.Suggestion var results []core.Suggestion
for _, line := range lines { for _, line := range lines {
@@ -65,17 +45,79 @@ func getGitResults(prefix string, args ...string) []core.Suggestion {
if line == "" || strings.HasPrefix(line, "*") { // skip active branch marker if any if line == "" || strings.HasPrefix(line, "*") { // skip active branch marker if any
line = strings.TrimSpace(strings.TrimPrefix(line, "*")) line = strings.TrimSpace(strings.TrimPrefix(line, "*"))
} }
if line == "" { if line == "" || line == activeBranch {
continue continue
} }
// handle remote branches that look like "remotes/origin/main"
line = strings.TrimPrefix(line, "remotes/")
suggestionCmd := line
suggestionDesc := args[0]
// for stash list, the format is "stash@{0}: message"
if args[0] == "stash" {
parts := strings.SplitN(line, ": ", 2)
if len(parts) == 2 {
suggestionCmd = parts[0]
suggestionDesc = parts[1]
}
}
results = append(results, core.Suggestion{ results = append(results, core.Suggestion{
Cmd: prefix + " " + line, Cmd: prefix + " " + suggestionCmd,
Desc: args[0], // "remote" or "branch" Desc: suggestionDesc,
}) })
} }
return results return results
} }
// GitBranchGenerator suggests git branches
func GitBranchGenerator(tokens []string, prefix string, partial string) []core.Suggestion {
// check if we are in "create" mode (-b or -B or -c)
isCreateMode := false
for _, t := range tokens {
if t == "-b" || t == "-B" || t == "-c" || t == "-C" {
isCreateMode = true
break
}
}
if isCreateMode {
return nil
}
return getGitResults(prefix, "branch", "-a", "--format=%(refname:short)")
}
// GitPushPullGenerator suggests remotes for the first arg, and branches for the second
func GitPushPullGenerator(tokens []string, prefix string, partial string) []core.Suggestion {
// Filter out flags to find positional arguments
args := []string{}
for i := 1; i < len(tokens); i++ {
t := tokens[i]
if t != "" && !strings.HasPrefix(t, "-") {
args = append(args, t)
}
}
// args[0] is subcommand (push/pull)
// args[1] should be remote
// args[2] should be branch
// If we only have subcommand, suggest remotes
if len(args) == 1 {
return GitRemoteGenerator(tokens, prefix, partial)
}
// If we have subcommand + remote, suggest branches
if len(args) == 2 {
return GitBranchGenerator(tokens, prefix, partial)
}
return nil
}
func init() { func init() {
core.Register(&core.Spec{ core.Register(&core.Spec{
Name: "git", Name: "git",
@@ -160,7 +202,16 @@ func init() {
{ {
Name: "checkout", Name: "checkout",
Description: "switch branches", Description: "switch branches",
Generator: GitBranchGenerator, Generator: func(tokens []string, prefix string, partial string) []core.Suggestion {
for _, t := range tokens {
if t == "-b" || t == "-B" {
return nil
}
}
branches := GitBranchGenerator(tokens, prefix, partial)
files := core.FileGenerator()(tokens, prefix, partial)
return append(branches, files...)
},
Options: []core.Option{ Options: []core.Option{
{Name: "-b", Description: "create new branch"}, {Name: "-b", Description: "create new branch"},
}, },
@@ -197,6 +248,7 @@ func init() {
{ {
Name: "rebase", Name: "rebase",
Description: "reapply commits", Description: "reapply commits",
Generator: GitBranchGenerator,
Options: []core.Option{ Options: []core.Option{
{Name: "-i", Description: "interactive"}, {Name: "-i", Description: "interactive"},
{Name: "--onto", Description: "rebase onto"}, {Name: "--onto", Description: "rebase onto"},
@@ -207,6 +259,7 @@ func init() {
{ {
Name: "log", Name: "log",
Description: "show commit log", Description: "show commit log",
Generator: core.FileGenerator(),
Options: []core.Option{ Options: []core.Option{
{Name: "--oneline", Description: "compact format"}, {Name: "--oneline", Description: "compact format"},
{Name: "--graph", Description: "show graph"}, {Name: "--graph", Description: "show graph"},
@@ -216,25 +269,33 @@ func init() {
{ {
Name: "diff", Name: "diff",
Description: "show changes", Description: "show changes",
Generator: core.FileGenerator(),
Options: []core.Option{ Options: []core.Option{
{Name: "--staged", Description: "staged changes"}, {Name: "--staged", Description: "staged changes"},
{Name: "--stat", Description: "diffstat only"}, {Name: "--stat", Description: "diffstat only"},
{Name: "--", Description: "separate paths"},
}, },
}, },
{ {
Name: "stash", Name: "tag",
Description: "stash changes", Description: "manage tags",
Subcommands: []core.Subcommand{ Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { return getGitResults(prefix, "tag", "-l") },
{Name: "pop", Description: "apply and drop"}, Options: []core.Option{
{Name: "apply", Description: "apply stash"}, {Name: "-a", Description: "annotated tag"},
{Name: "drop", Description: "remove stash"}, {Name: "-d", Description: "delete tag"},
{Name: "list", Description: "list stashes"}, {Name: "-l", Description: "list tags"},
{Name: "show", Description: "show stash diff"}, {Name: "--delete", Description: "delete tag"},
{Name: "-m", Description: "tag message"},
}, },
}, },
{ {
Name: "reset", Name: "reset",
Description: "reset HEAD", Description: "reset HEAD",
Generator: func(tokens []string, prefix string, partial string) []core.Suggestion {
branches := GitBranchGenerator(tokens, prefix, partial)
files := core.FileGenerator()(tokens, prefix, partial)
return append(branches, files...)
},
Options: []core.Option{ Options: []core.Option{
{Name: "--hard", Description: "discard changes"}, {Name: "--hard", Description: "discard changes"},
{Name: "--soft", Description: "keep staged"}, {Name: "--soft", Description: "keep staged"},
@@ -242,21 +303,45 @@ func init() {
}, },
}, },
{ {
Name: "tag", Name: "restore",
Description: "manage tags", Description: "restore working tree files",
Generator: core.FileGenerator(),
Options: []core.Option{ Options: []core.Option{
{Name: "-a", Description: "annotated tag"}, {Name: "-s", Description: "source tree"},
{Name: "-d", Description: "delete tag"}, {Name: "-W", Description: "working tree"},
{Name: "-l", Description: "list tags"}, },
},
{
Name: "rm",
Description: "remove files",
Generator: core.FileGenerator(),
Options: []core.Option{
{Name: "-f", Description: "force"},
{Name: "-r", Description: "recursive"},
{Name: "--cached", Description: "unstage only"},
},
},
{
Name: "stash",
Description: "stash changes",
Subcommands: []core.Subcommand{
{Name: "pop", Description: "apply and drop", Generator: GitStashGenerator, Options: []core.Option{{Name: "--index", Description: "try to reinstate index"}}},
{Name: "apply", Description: "apply stash", Generator: GitStashGenerator},
{Name: "drop", Description: "remove stash", Generator: GitStashGenerator},
{Name: "list", Description: "list stashes"},
{Name: "show", Description: "show stash diff", Generator: GitStashGenerator},
{Name: "push", Description: "push to stash"},
{Name: "branch", Description: "create branch from stash", Generator: GitBranchGenerator},
}, },
}, },
{ {
Name: "remote", Name: "remote",
Description: "manage remotes", Description: "manage remotes",
Subcommands: []core.Subcommand{ Subcommands: []core.Subcommand{
{Name: "add", Description: "add remote"}, {Name: "add", Description: "add remote", Options: []core.Option{{Name: "-f", Description: "fetch immediately"}}},
{Name: "remove", Description: "remove remote"}, {Name: "remove", Description: "remove remote", Generator: GitRemoteGenerator},
{Name: "rename", Description: "rename remote"}, {Name: "rename", Description: "rename remote", Generator: GitRemoteGenerator},
{Name: "set-url", Description: "change remote url", Generator: GitRemoteGenerator},
{Name: "-v", Description: "verbose list"}, {Name: "-v", Description: "verbose list"},
}, },
}, },
+156
View File
@@ -0,0 +1,156 @@
package tests
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/versenilvis/iris/commands/core"
_ "github.com/versenilvis/iris/commands/dev"
)
func TestGitSuggestions(t *testing.T) {
// Setup a real git repo in temp dir for testing branch generators
tmp := t.TempDir()
oldWd, _ := os.Getwd()
_ = os.Chdir(tmp)
defer func() { _ = os.Chdir(oldWd) }()
ctx := context.Background()
// Initialize git repo
_ = exec.CommandContext(ctx, "git", "init").Run()
_ = os.WriteFile(filepath.Join(tmp, "file.go"), []byte("package main"), 0644)
_ = exec.CommandContext(ctx, "git", "add", ".").Run()
_ = exec.CommandContext(ctx, "git", "commit", "-m", "initial").Run()
// Create branches
_ = exec.CommandContext(ctx, "git", "branch", "feature/login").Run()
_ = exec.CommandContext(ctx, "git", "branch", "dev").Run()
_ = exec.CommandContext(ctx, "git", "tag", "v1.0").Run()
// Setup stash
_ = os.WriteFile(filepath.Join(tmp, "dirty.go"), []byte("dirty"), 0644)
_ = exec.CommandContext(ctx, "git", "add", ".").Run()
_ = exec.CommandContext(ctx, "git", "stash").Run()
t.Run("git top-level", func(t *testing.T) {
res := core.Lookup("git ")
if len(res) < 10 {
t.Errorf("Expected many git subcommands, got %d", len(res))
}
})
t.Run("git tag -d show tags", func(t *testing.T) {
res := core.Lookup("git tag -d ")
found := false
for _, r := range res {
if strings.Contains(r.Cmd, "v1.0") {
found = true
}
}
if !found {
t.Error("git tag -d should suggest existing tags")
}
})
t.Run("git push HEAD options", func(t *testing.T) {
// git push origin HEAD --force -> should show --force
res := core.Lookup("git push origin HEAD --")
found := false
for _, r := range res {
if strings.Contains(r.Cmd, "--force") {
found = true
}
}
if !found {
t.Error("git push origin HEAD -- should suggest --force")
}
})
t.Run("git push upstream options", func(t *testing.T) {
// git push -u origin -> show branches
res := core.Lookup("git push -u origin ")
found := false
for _, r := range res {
if strings.Contains(r.Cmd, "dev") {
found = true
}
}
if !found {
t.Error("git push -u origin should suggest branches")
}
})
t.Run("git reset options", func(t *testing.T) {
// git reset --soft origin/main -> should be accepted (just testing lookup doesn't crash)
_ = core.Lookup("git reset --soft origin/main ")
// git reset HEAD -> show files
res := core.Lookup("git reset HEAD ")
found := false
for _, r := range res {
if strings.Contains(r.Cmd, "file.go") {
found = true
}
}
if !found {
t.Error("git reset HEAD should suggest file.go")
}
})
t.Run("git checkout -b no suggest", func(t *testing.T) {
// git checkout -b -> should NOT suggest branches
res := core.Lookup("git checkout -b ")
for _, r := range res {
if strings.Contains(r.Cmd, "dev") {
t.Error("git checkout -b should not suggest existing branches")
}
}
})
t.Run("git switch -c no suggest", func(t *testing.T) {
res := core.Lookup("git switch -c ")
for _, r := range res {
if strings.Contains(r.Cmd, "dev") {
t.Error("git switch -c should not suggest existing branches")
}
}
})
t.Run("stash entries", func(t *testing.T) {
res := core.Lookup("git stash pop ")
found := false
for _, r := range res {
if strings.Contains(r.Cmd, "stash@{0}") {
found = true
}
}
if !found {
t.Error("git stash pop should suggest stash@{0}")
}
})
t.Run("not a git repo", func(t *testing.T) {
emptyDir := t.TempDir()
_ = os.Chdir(emptyDir)
// Should not crash
_ = core.Lookup("git status ")
_ = os.Chdir(tmp)
})
t.Run("active branch filter", func(t *testing.T) {
// find current branch
out, _ := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output()
current := strings.TrimSpace(string(out))
res := core.Lookup("git checkout ")
for _, r := range res {
if strings.Contains(r.Cmd, current) && !strings.Contains(r.Cmd, "remotes/") {
t.Errorf("Should not suggest active branch '%s'", current)
}
}
})
}