From b41ae3f2a01dc552b5cddbf9794c4729fa96a3e4 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 02:23:56 +0700 Subject: [PATCH 1/4] feat: nightly built --- .github/workflows/nightly.yml | 76 +++++++++++++++++++++++++++++++++++ root/update.go | 5 +++ 2 files changed, 81 insertions(+) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..794f559 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,76 @@ +name: Nightly + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + nightly: + name: Nightly Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed for git describe + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Get version info + id: version + run: | + BASE=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + SHORT=$(git rev-parse --short HEAD) + echo "version=${BASE}-nightly.${SHORT}" >> $GITHUB_OUTPUT + + - name: Build Binaries + run: | + mkdir -p dist + platforms=("linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64") + for platform in "${platforms[@]}"; do + os_arch=(${platform//\// }) + GOOS=${os_arch[0]} + GOARCH=${os_arch[1]} + + GOOS=$GOOS GOARCH=$GOARCH go build \ + -ldflags="-s -w -X github.com/versenilvis/iris/root.Version=${{ steps.version.outputs.version }}" \ + -trimpath -o "dist/iris" main.go + + cd dist + tar -czf "iris_${GOOS}_${GOARCH}.tar.gz" "iris" + rm "iris" + cd .. + done + + - name: Delete existing nightly release + run: | + gh release delete nightly --yes 2>/dev/null || true + git tag -d nightly 2>/dev/null || true + git push origin --delete nightly 2>/dev/null || true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Nightly Release + uses: softprops/action-gh-release@v1 + with: + tag_name: nightly + name: ${{ steps.version.outputs.version }} + body: | + 🌙 **Nightly build** — `${{ steps.version.outputs.version }}` + + auto-built from commit `${{ github.sha }}` + + > install manually, this build will **not** trigger an update notification + files: dist/*.tar.gz + prerelease: true + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/root/update.go b/root/update.go index a542f5a..a6aeec9 100644 --- a/root/update.go +++ b/root/update.go @@ -122,6 +122,11 @@ func IsNewer(current, latest string) bool { return false } + // nightly builds are never shown as stable update targets + if strings.Contains(l, "-nightly.") { + return false + } + if c == l { return false } From ce32add3f1bd6324eaf8a2c205a301198e117205 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 02:40:07 +0700 Subject: [PATCH 2/4] fix(git): git doesnt show branch suggesstion properly --- .github/workflows/nightly.yml | 2 +- commands/core/spec.go | 2 +- commands/dev/git.go | 120 ++++++++++------ tests/dev/git_test.go | 257 ++++++++++++++++++++++++++-------- 4 files changed, 282 insertions(+), 99 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 794f559..7b5631b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -64,7 +64,7 @@ jobs: tag_name: nightly name: ${{ steps.version.outputs.version }} body: | - 🌙 **Nightly build** — `${{ steps.version.outputs.version }}` + 🌙 **Nightly build** - `${{ steps.version.outputs.version }}` auto-built from commit `${{ github.sha }}` diff --git a/commands/core/spec.go b/commands/core/spec.go index 65f3b61..1a4d345 100644 --- a/commands/core/spec.go +++ b/commands/core/spec.go @@ -45,7 +45,7 @@ func Register(s *Spec) { Registry[s.Name] = s } -// ResetRegistry clears all registered specs — use in tests only +// ResetRegistry clears all registered specs - use in tests only func ResetRegistry() { Registry = make(map[string]*Spec) } diff --git a/commands/dev/git.go b/commands/dev/git.go index dfea768..722b4ae 100644 --- a/commands/dev/git.go +++ b/commands/dev/git.go @@ -9,16 +9,20 @@ import ( ) // GitRemoteGenerator suggests git remotes -func GitRemoteGenerator(tokens []string, prefix string, partial string) []core.Suggestion { - return getGitResults(prefix, "remote") +func GitRemoteGenerator(tokens []string, prefix string, _ string) []core.Suggestion { + return getGitResults(tokens, prefix, "remote") } // GitStashGenerator suggests git stashes -func GitStashGenerator(tokens []string, prefix string, partial string) []core.Suggestion { - return getGitResults(prefix, "stash", "list", "--format=%gd: %gs") +func GitStashGenerator(tokens []string, prefix string, _ string) []core.Suggestion { + return getGitResults(tokens, prefix, "stash", "list", "--format=%gd: %gs") } -func getGitResults(prefix string, args ...string) []core.Suggestion { +func getGitResults(tokens []string, _ string, args ...string) []core.Suggestion { + return getGitResultsFiltered(tokens, "_", false, args...) +} + +func getGitResultsFiltered(tokens []string, _ string, localOnly bool, args ...string) []core.Suggestion { cwd := core.GetCWD() cmd := exec.CommandContext(context.Background(), "git", args...) cmd.Dir = cwd @@ -28,9 +32,7 @@ func getGitResults(prefix string, args ...string) []core.Suggestion { } activeBranch := "" - switch args[0] { - case "branch": - // Try to find the current active branch to filter it out later + if args[0] == "branch" { activeCmd := exec.CommandContext(context.Background(), "git", "rev-parse", "--abbrev-ref", "HEAD") activeCmd.Dir = cwd if activeOut, err := activeCmd.Output(); err == nil { @@ -38,24 +40,52 @@ func getGitResults(prefix string, args ...string) []core.Suggestion { } } + seen := make(map[string]bool) lines := strings.Split(string(out), "\n") var results []core.Suggestion for _, line := range lines { line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "*") { // skip active branch marker if any - line = strings.TrimSpace(strings.TrimPrefix(line, "*")) + if line == "" { + continue } - if line == "" || line == activeBranch { + + isRemote := strings.HasPrefix(line, "remotes/") + + // skip remote tracking branches if localOnly mode + if localOnly && isRemote { continue } - - // handle remote branches that look like "remotes/origin/main" - line = strings.TrimPrefix(line, "remotes/") + + // strip remotes/ prefix to get the usable form: origin/main + if isRemote { + line = strings.TrimPrefix(line, "remotes/") + } + + // only skip active branch for checkout/switch commands + isCheckoutOrSwitch := false + if len(tokens) > 1 && (tokens[1] == "checkout" || tokens[1] == "switch") { + isCheckoutOrSwitch = true + } + + if isCheckoutOrSwitch { + if line == activeBranch || line == "origin/"+activeBranch { + continue + } + } + + // dedup: origin/dev and dev would both appear with -a; skip if already seen the short name + shortName := line + if idx := strings.Index(line, "/"); isRemote && idx != -1 { + shortName = line[idx+1:] // "origin/dev" → "dev" + } + if seen[shortName] { + continue + } + seen[shortName] = true 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 { @@ -65,15 +95,16 @@ func getGitResults(prefix string, args ...string) []core.Suggestion { } results = append(results, core.Suggestion{ - Cmd: prefix + " " + suggestionCmd, + Cmd: suggestionCmd, Desc: suggestionDesc, }) } return results } -// GitBranchGenerator suggests git branches -func GitBranchGenerator(tokens []string, prefix string, partial string) []core.Suggestion { + +// GitBranchGenerator suggests git branches (local + remote, deduped) +func GitBranchGenerator(tokens []string, prefix string, _ string) []core.Suggestion { // check if we are in "create" mode (-b or -B or -c) isCreateMode := false for _, t := range tokens { @@ -87,35 +118,46 @@ func GitBranchGenerator(tokens []string, prefix string, partial string) []core.S return nil } - return getGitResults(prefix, "branch", "-a", "--format=%(refname:short)") + return getGitResults(tokens, prefix, "branch", "-a", "--format=%(refname:short)") } -// GitPushPullGenerator suggests remotes for the first arg, and branches for the second +// gitLocalBranchGenerator is like GitBranchGenerator but only local branches +// used for push/pull where remote tracking branches cause duplicates +func gitLocalBranchGenerator(tokens []string, prefix string, _ string) []core.Suggestion { + isCreateMode := false + for _, t := range tokens { + if t == "-b" || t == "-B" || t == "-c" || t == "-C" { + isCreateMode = true + break + } + } + if isCreateMode { + return nil + } + return getGitResultsFiltered(tokens, prefix, true, "branch", "-a", "--format=%(refname:short)") +} + + 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++ { + // count completed positional args (not flags, not the partial being typed) + // tokens[0] = "git", tokens[1] = "push"/"pull", so start at 2 + // exclude tokens[len-1] because that's the partial being typed + pArgs := []string{} + for i := 2; i < len(tokens)-1; i++ { t := tokens[i] - if t != "" && !strings.HasPrefix(t, "-") { - args = append(args, t) + if t == "" || strings.HasPrefix(t, "-") { + continue } + pArgs = append(pArgs, 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 { + // no remote confirmed yet, suggest remotes + if len(pArgs) == 0 { return GitRemoteGenerator(tokens, prefix, partial) } - - // If we have subcommand + remote, suggest branches - if len(args) == 2 { - return GitBranchGenerator(tokens, prefix, partial) - } - - return nil + + // remote is set, suggest local branches only (no duplicates with origin/xxx) + return gitLocalBranchGenerator(tokens, prefix, partial) } func init() { @@ -279,7 +321,7 @@ func init() { { Name: "tag", Description: "manage tags", - Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { return getGitResults(prefix, "tag", "-l") }, + Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { return getGitResults(tokens, prefix, "tag", "-l") }, Options: []core.Option{ {Name: "-a", Description: "annotated tag"}, {Name: "-d", Description: "delete tag"}, diff --git a/tests/dev/git_test.go b/tests/dev/git_test.go index 2b01912..04efc1b 100644 --- a/tests/dev/git_test.go +++ b/tests/dev/git_test.go @@ -12,42 +12,101 @@ import ( _ "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) }() +// setupGitRepo creates a real git repo in a temp dir with: +// - local branches: main (HEAD), dev, feature/login, stable +// - remote branches: origin/main, origin/dev (written directly to .git/refs) +// - a tag: v1.0 +// - a stash entry +func setupGitRepo(t *testing.T) (tmp string, cleanup func()) { + t.Helper() + tmp = t.TempDir() ctx := context.Background() - // Initialize git repo - _ = exec.CommandContext(ctx, "git", "init").Run() - _ = exec.CommandContext(ctx, "git", "config", "user.email", "iris-test@example.com").Run() // this is for ci/cd - _ = exec.CommandContext(ctx, "git", "config", "user.name", "Iris Test").Run() // this is for ci/cd + run := func(args ...string) { + t.Helper() + out, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput() + if err != nil { + t.Logf("git cmd %v: %s", args, out) + } + } + + run("git", "-C", tmp, "init", "--initial-branch=main") + + // use fallback for older git that doesn't support --initial-branch + if _, err := os.Stat(filepath.Join(tmp, ".git", "refs", "heads", "main")); err != nil { + run("git", "-C", tmp, "init") + } + + run("git", "-C", tmp, "config", "user.email", "iris-test@example.com") // this is for ci/cd + run("git", "-C", tmp, "config", "user.name", "Iris Test") // this is for ci/cd + + // initial commit so branches can be created + if err := os.WriteFile(filepath.Join(tmp, "file.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + run("git", "-C", tmp, "add", ".") + run("git", "-C", tmp, "commit", "-m", "initial") + + // local branches (incl. slash branch to test tokenization) + run("git", "-C", tmp, "branch", "dev") + run("git", "-C", tmp, "branch", "stable") + run("git", "-C", tmp, "branch", "feature/login") + + // tag + run("git", "-C", tmp, "tag", "v1.0") + + // add a real remote in config + run("git", "-C", tmp, "remote", "add", "origin", "https://github.com/versenilvis/iris.git") - _ = 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() + // write fake remote refs directly (no need for actual remote server) + for _, ref := range []string{"main", "dev"} { + dir := filepath.Join(tmp, ".git", "refs", "remotes", "origin") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + // point them to the same commit as HEAD for simplicity + headBytes, err := os.ReadFile(filepath.Join(tmp, ".git", "refs", "heads", "main")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ref), headBytes, 0644); err != nil { + t.Fatal(err) + } + } - // 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() + // stash entry + if err := os.WriteFile(filepath.Join(tmp, "dirty.go"), []byte("dirty"), 0644); err != nil { + t.Fatal(err) + } + run("git", "-C", tmp, "add", ".") + run("git", "-C", tmp, "stash") - // Setup stash - _ = os.WriteFile(filepath.Join(tmp, "dirty.go"), []byte("dirty"), 0644) - _ = exec.CommandContext(ctx, "git", "add", ".").Run() - _ = exec.CommandContext(ctx, "git", "stash").Run() + // chdir into repo so generators can run git commands + oldWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + + cleanup = func() { _ = os.Chdir(oldWd) } + return tmp, cleanup +} + +func TestGitSuggestions(t *testing.T) { + tmp, cleanup := setupGitRepo(t) + defer cleanup() 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.Errorf("expected many git subcommands, got %d", len(res)) } }) - t.Run("git tag -d show tags", func(t *testing.T) { + t.Run("tag -d shows tags", func(t *testing.T) { res := core.Lookup("git tag -d ") found := false for _, r := range res { @@ -56,12 +115,11 @@ func TestGitSuggestions(t *testing.T) { } } if !found { - t.Error("git tag -d should suggest existing tags") + t.Error("git tag -d should suggest v1.0") } }) - t.Run("git push HEAD options", func(t *testing.T) { - // git push origin HEAD --force -> should show --force + t.Run("push HEAD options", func(t *testing.T) { res := core.Lookup("git push origin HEAD --") found := false for _, r := range res { @@ -74,8 +132,7 @@ func TestGitSuggestions(t *testing.T) { } }) - t.Run("git push upstream options", func(t *testing.T) { - // git push -u origin -> show branches + t.Run("push -u origin suggests branches", func(t *testing.T) { res := core.Lookup("git push -u origin ") found := false for _, r := range res { @@ -84,29 +141,95 @@ func TestGitSuggestions(t *testing.T) { } } if !found { - t.Error("git push -u origin should suggest branches") + t.Errorf("git push -u origin should suggest branches, got: %v", res) } }) - 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 ") + t.Run("push origin suggests active branch", func(t *testing.T) { + ctx := context.Background() + out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + t.Skip("can't determine HEAD branch") + } + activeBranch := strings.TrimSpace(string(out)) + res := core.Lookup("git push origin ") + found := false + for _, r := range res { + parts := strings.Fields(r.Cmd) + if len(parts) > 0 && parts[len(parts)-1] == activeBranch { + found = true + break + } + } + if !found { + t.Errorf("git push origin should suggest active branch '%s'", activeBranch) + } + }) - // git reset HEAD -> show files - res := core.Lookup("git reset HEAD ") + t.Run("push origin no duplicate branches", func(t *testing.T) { + res := core.Lookup("git push origin ") + seen := make(map[string]int) + for _, r := range res { + parts := strings.Fields(r.Cmd) + if len(parts) == 0 { + continue + } + branch := parts[len(parts)-1] + seen[branch]++ + if seen[branch] > 1 { + t.Errorf("duplicate branch suggestion: %s", branch) + } + } + }) + + + t.Run("branch with slash is suggested correctly", func(t *testing.T) { + res := core.Lookup("git checkout ") found := false for _, r := range res { - if strings.Contains(r.Cmd, "file.go") { + if strings.Contains(r.Cmd, "feature/login") { found = true } } if !found { - t.Error("git reset HEAD should suggest file.go") + t.Error("git checkout should suggest 'feature/login'") } }) - t.Run("git checkout -b no suggest", func(t *testing.T) { - // git checkout -b -> should NOT suggest branches + t.Run("remote branches suggested for push", func(t *testing.T) { + res := core.Lookup("git push origin ") + cmdStr := "" + for _, r := range res { + cmdStr += r.Cmd + " " + } + // should have at least dev or main from branch list + if !strings.Contains(cmdStr, "dev") && !strings.Contains(cmdStr, "main") { + t.Errorf("git push origin should suggest local branches, got: %s", cmdStr) + } + }) + + t.Run("active branch not suggested for checkout", func(t *testing.T) { + // find actual active branch + ctx := context.Background() + out, err := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + t.Skip("can't determine HEAD branch") + } + activeBranch := strings.TrimSpace(string(out)) + + res := core.Lookup("git checkout ") + for _, r := range res { + // the suggestion should not contain the active branch as a standalone word + parts := strings.Fields(r.Cmd) + for _, p := range parts { + if p == activeBranch { + t.Errorf("git checkout should not suggest active branch '%s', got: %s", activeBranch, r.Cmd) + } + } + } + }) + + t.Run("checkout -b no suggest", func(t *testing.T) { res := core.Lookup("git checkout -b ") for _, r := range res { if strings.Contains(r.Cmd, "dev") { @@ -115,7 +238,7 @@ func TestGitSuggestions(t *testing.T) { } }) - t.Run("git switch -c no suggest", func(t *testing.T) { + t.Run("switch -c no suggest", func(t *testing.T) { res := core.Lookup("git switch -c ") for _, r := range res { if strings.Contains(r.Cmd, "dev") { @@ -124,37 +247,55 @@ func TestGitSuggestions(t *testing.T) { } }) - 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 + t.Run("stash variants suggest entries", func(t *testing.T) { + for _, cmd := range []string{"apply", "drop", "pop"} { + res := core.Lookup("git stash " + cmd + " ") + found := false + for _, r := range res { + if strings.Contains(r.Cmd, "stash@{0}") { + found = true + } + } + if !found { + t.Errorf("git stash %s should suggest stash@{0}", cmd) } } - if !found { - t.Error("git stash pop should suggest stash@{0}") + }) + + t.Run("remote subcommands suggest remotes", func(t *testing.T) { + for _, cmd := range []string{"remove", "rename", "set-url"} { + res := core.Lookup("git remote " + cmd + " ") + found := false + for _, r := range res { + // origin is our fake remote + if strings.Contains(r.Cmd, "origin") { + found = true + } + } + if !found { + t.Errorf("git remote %s should suggest origin", cmd) + } } }) - t.Run("not a git repo", func(t *testing.T) { + t.Run("not a git repo no crash", func(t *testing.T) { emptyDir := t.TempDir() _ = os.Chdir(emptyDir) - // Should not crash + defer func() { _ = os.Chdir(tmp) }() _ = 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 ") + t.Run("reset options", func(t *testing.T) { + _ = core.Lookup("git reset --soft origin/main ") + res := core.Lookup("git reset HEAD ") + found := false for _, r := range res { - if strings.Contains(r.Cmd, current) && !strings.Contains(r.Cmd, "remotes/") { - t.Errorf("Should not suggest active branch '%s'", current) + if strings.Contains(r.Cmd, "file.go") { + found = true } } + if !found { + t.Error("git reset HEAD should suggest file.go") + } }) } From 2e03c25603deb515673dbc061ceb1b8979742b18 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 02:41:55 +0700 Subject: [PATCH 3/4] chore(test): nightly release test --- .github/workflows/nightly.yml | 1 + tests/root/update_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7b5631b..6990c18 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: write diff --git a/tests/root/update_test.go b/tests/root/update_test.go index 7a32314..b3993f2 100644 --- a/tests/root/update_test.go +++ b/tests/root/update_test.go @@ -22,6 +22,8 @@ func TestIsNewer(t *testing.T) { {"dev", "v1.0.0", false}, // dev never updates {"v1.0.0", "dev", false}, {"", "v1.0.0", false}, + {"v1.0.0", "v1.1.0-nightly.8cb1f47", false}, // nightly never triggers update + {"v1.1.0-nightly.abc", "v1.2.0", true}, // but if you are on nightly, you can update to stable } for _, tt := range tests { From 6b682db6e2a7dbb744d19fd9b1ac2c77e7af7a7c Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 02:44:54 +0700 Subject: [PATCH 4/4] fix: git --- commands/dev/git.go | 60 ++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/commands/dev/git.go b/commands/dev/git.go index 722b4ae..47deb46 100644 --- a/commands/dev/git.go +++ b/commands/dev/git.go @@ -4,27 +4,32 @@ import ( "context" "os/exec" "strings" + "time" "github.com/versenilvis/iris/commands/core" ) // GitRemoteGenerator suggests git remotes -func GitRemoteGenerator(tokens []string, prefix string, _ string) []core.Suggestion { - return getGitResults(tokens, prefix, "remote") +func GitRemoteGenerator(tokens []string, _ string, _ string) []core.Suggestion { + return getGitResults(tokens, "remote") } // GitStashGenerator suggests git stashes -func GitStashGenerator(tokens []string, prefix string, _ string) []core.Suggestion { - return getGitResults(tokens, prefix, "stash", "list", "--format=%gd: %gs") +func GitStashGenerator(tokens []string, _ string, _ string) []core.Suggestion { + return getGitResults(tokens, "stash", "list", "--format=%gd: %gs") } -func getGitResults(tokens []string, _ string, args ...string) []core.Suggestion { - return getGitResultsFiltered(tokens, "_", false, args...) +func getGitResults(tokens []string, args ...string) []core.Suggestion { + return getGitResultsFiltered(tokens, false, args...) } -func getGitResultsFiltered(tokens []string, _ string, localOnly bool, args ...string) []core.Suggestion { +func getGitResultsFiltered(tokens []string, localOnly bool, args ...string) []core.Suggestion { cwd := core.GetCWD() - cmd := exec.CommandContext(context.Background(), "git", args...) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = cwd out, err := cmd.Output() if err != nil { @@ -33,7 +38,7 @@ func getGitResultsFiltered(tokens []string, _ string, localOnly bool, args ...st activeBranch := "" if args[0] == "branch" { - activeCmd := exec.CommandContext(context.Background(), "git", "rev-parse", "--abbrev-ref", "HEAD") + activeCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") activeCmd.Dir = cwd if activeOut, err := activeCmd.Output(); err == nil { activeBranch = strings.TrimSpace(string(activeOut)) @@ -43,6 +48,16 @@ func getGitResultsFiltered(tokens []string, _ string, localOnly bool, args ...st seen := make(map[string]bool) lines := strings.Split(string(out), "\n") var results []core.Suggestion + + // more robust subcommand detection: find the first non-flag after "git" + subcommand := "" + for i := 1; i < len(tokens); i++ { + if !strings.HasPrefix(tokens[i], "-") { + subcommand = tokens[i] + break + } + } + for _, line := range lines { line = strings.TrimSpace(line) if line == "" { @@ -62,21 +77,22 @@ func getGitResultsFiltered(tokens []string, _ string, localOnly bool, args ...st } // only skip active branch for checkout/switch commands - isCheckoutOrSwitch := false - if len(tokens) > 1 && (tokens[1] == "checkout" || tokens[1] == "switch") { - isCheckoutOrSwitch = true - } - - if isCheckoutOrSwitch { - if line == activeBranch || line == "origin/"+activeBranch { + if subcommand == "checkout" || subcommand == "switch" { + if line == activeBranch { continue } + // also skip any remote branch that tracks the active branch (e.g. origin/main) + if idx := strings.Index(line, "/"); isRemote && idx != -1 { + if line[idx+1:] == activeBranch { + continue + } + } } // dedup: origin/dev and dev would both appear with -a; skip if already seen the short name shortName := line if idx := strings.Index(line, "/"); isRemote && idx != -1 { - shortName = line[idx+1:] // "origin/dev" → "dev" + shortName = line[idx+1:] // "origin/dev" -> "dev" } if seen[shortName] { continue @@ -104,7 +120,7 @@ func getGitResultsFiltered(tokens []string, _ string, localOnly bool, args ...st // GitBranchGenerator suggests git branches (local + remote, deduped) -func GitBranchGenerator(tokens []string, prefix string, _ string) []core.Suggestion { +func GitBranchGenerator(tokens []string, _ string, _ string) []core.Suggestion { // check if we are in "create" mode (-b or -B or -c) isCreateMode := false for _, t := range tokens { @@ -118,12 +134,12 @@ func GitBranchGenerator(tokens []string, prefix string, _ string) []core.Suggest return nil } - return getGitResults(tokens, prefix, "branch", "-a", "--format=%(refname:short)") + return getGitResults(tokens, "branch", "-a", "--format=%(refname:short)") } // gitLocalBranchGenerator is like GitBranchGenerator but only local branches // used for push/pull where remote tracking branches cause duplicates -func gitLocalBranchGenerator(tokens []string, prefix string, _ string) []core.Suggestion { +func gitLocalBranchGenerator(tokens []string, _ string, _ string) []core.Suggestion { isCreateMode := false for _, t := range tokens { if t == "-b" || t == "-B" || t == "-c" || t == "-C" { @@ -134,7 +150,7 @@ func gitLocalBranchGenerator(tokens []string, prefix string, _ string) []core.Su if isCreateMode { return nil } - return getGitResultsFiltered(tokens, prefix, true, "branch", "-a", "--format=%(refname:short)") + return getGitResultsFiltered(tokens, true, "branch", "-a", "--format=%(refname:short)") } @@ -321,7 +337,7 @@ func init() { { Name: "tag", Description: "manage tags", - Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { return getGitResults(tokens, prefix, "tag", "-l") }, + Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { return getGitResults(tokens, "tag", "-l") }, Options: []core.Option{ {Name: "-a", Description: "annotated tag"}, {Name: "-d", Description: "delete tag"},