Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions features/command_branch.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Feature: branch command
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really appreciate these integration tests!


Background:
Given a mocked home directory

@outside-repo
Scenario: Appropriate error status when not in a git repo
When I run `scmpuff branch`
Then the exit status should be 128
And the output should contain:
"""
Not a git repository (or any of the parent directories)
"""

Scenario: Numbered output of local branches
Given I am in a git repository
And a file named "base" with:
"""
foo
"""
And I successfully run `git add base`
And I successfully run `git commit -m "init"`
And I successfully run the following commands:
| git branch branch_a |
| git branch branch_b |
When I successfully run `scmpuff branch`
Then the stdout from "scmpuff branch" should contain "* [1] master"
And the stdout from "scmpuff branch" should contain " [2] branch_a"
And the stdout from "scmpuff branch" should contain " [3] branch_b"

Scenario: Detached HEAD output
Given I am in a git repository
And a file named "foo" with:
"""
bar
"""
And I successfully run `git add foo`
And I successfully run `git commit -m "first"`
And I successfully run `git branch feature`
And I run `git checkout HEAD~0`
When I successfully run `scmpuff branch`
Then the stdout from "scmpuff branch" should match /\* \(HEAD detached/
And the stdout from "scmpuff branch" should contain "[1] master"
And the stdout from "scmpuff branch" should contain "[2] feature"

105 changes: 105 additions & 0 deletions internal/commands/branch/branch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package branch

import (
"bufio"
"bytes"
"fmt"
"log"
"os"
"os/exec"
"strings"

"github.com/spf13/cobra"
)

const (
colorBranch = "\033[32m"
colorReset = "\033[0m"
)

// CommandBranch lists git branches with numbered shortcuts.
// The first line of output, when --branchlist is provided, will contain
// a TAB separated list of branch names suitable for environment expansion.
func CommandBranch() *cobra.Command {
var optsBranchlist bool

var branchCmd = &cobra.Command{
Use: "branch",
Short: "Display numbered git branches",
Run: func(cmd *cobra.Command, args []string) {
branches := gitBranchOutput()
numbered, list := process(branches)

if optsBranchlist {
fmt.Println(strings.Join(list, "\t"))
}
fmt.Print(numbered)
},
}

branchCmd.Flags().BoolVarP(
&optsBranchlist,
"branchlist", "f", false,
"include machine-parseable branch list",
)

return branchCmd
}

func gitBranchOutput() []byte {
out, err := exec.Command("git", "branch", "--color=never").Output()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like there unfortunately isn't a --porcelain (guaranteed not to change across git versions) version of git branch output like there is with git status? I'm wondering how to make the parsing resilient to multiple versions of git in the wild (including future versions). Of course, once I get around to #33 that could be a good long term solution. Another possibility while relying on CLI git, do you think it might make sense to use something like --format to specify a specific format? I'm not sure if that might be more resilient to change.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another edge case to consider might be whether there is a way to ensure the pager is disabled on a per command basis here, so git doesn't try to run its output through a pager if there more lines of branches than it thinks will fit on the user screen. From the git branch documentation it appears that is the default, we might want to verify if it is automatically disabled somehow based on detecting whether STDOUT is a pipe or tty.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--format w/ pager forcibly disabled makes sense to me here!

if err != nil {
if err.Error() == "exit status 128" {
msg := "Not a git repository (or any of the parent directories)"
fmt.Fprintf(os.Stderr, "\033[0;31m%s\033[0m\n", msg)
os.Exit(128)
}
log.Fatal(err)
}
return out
}

// process takes raw `git branch` output and returns numbered output
// along with a slice of branch names in order.
func process(out []byte) (string, []string) {
scanner := bufio.NewScanner(bytes.NewReader(out))
var starLine string
var starBranch string
var names []string

for scanner.Scan() {
line := scanner.Text()
if len(line) < 2 {
continue
}
prefix := line[:2]
name := strings.TrimSpace(line[2:])
if prefix == "* " {
starLine = line
if !strings.HasPrefix(name, "(") {
starBranch = name
}
continue
}
names = append(names, name)
}

var b strings.Builder
var result []string
n := 1
if starLine != "" {
if starBranch != "" {
b.WriteString(fmt.Sprintf("* [%d] %s%s%s\n", n, colorBranch, starBranch, colorReset))
result = append(result, starBranch)
n++
} else {
b.WriteString(colorBranch + starLine + colorReset + "\n")
}
}
for _, name := range names {
b.WriteString(fmt.Sprintf(" [%d] %s\n", n, name))
result = append(result, name)
n++
}
return b.String(), result
}
1 change: 1 addition & 0 deletions internal/commands/inits/data/aliases.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
alias gs='scmpuff_status'
alias gb='scmpuff_branch'
alias ga='git add'
alias gd='git diff'
alias gl='git log'
Expand Down
22 changes: 22 additions & 0 deletions internal/commands/inits/data/status_shortcuts.fish
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@ function scmpuff_status
end
end

function scmpuff_branch
scmpuff_clear_vars
set -lx scmpuff_env_char "e"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clobbering the existing file variable could be problematic: it might not be obvious to a user that if they list branches, the previous file shortcuts that were displayed to them would stop. working. I suspect we need a second variable here (which unfortunately does add to implementation complexity, but I don't know a great way around that).

Copy link
Author

@hansonw hansonw May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was previously a very active user of scm_breeze (I definitely prefer the simplicity of scmpuff!) and fwiw it shared the same env var space between all numbered commands. It definitely wasn’t ideal but was pretty intuitive I think

set -l cmd_output (/usr/bin/env scmpuff branch --branchlist $argv)
set -l es "$status"

if test $es -ne 0
return $es
end

set -l files (string split \t $cmd_output[1])
if test (count $files) -gt 0
for e in (seq (count $files))
set -gx "$scmpuff_env_char""$e" "$files[$e]"
end
end

for line in $cmd_output[2..-1]
echo $line
end
end

function scmpuff_clear_vars
set -l scmpuff_env_char "e"
set -l scmpuff_env_vars (set -x | awk '{print $1}' | grep -E '^'$scmpuff_env_char'[0-9]+')
Expand Down
32 changes: 31 additions & 1 deletion internal/commands/inits/data/status_shortcuts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ scmpuff_status() {
local e=1
for file in $files; do
export $scmpuff_env_char$e="$file"
let e++
(( e++ ))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this syntax fully POSIX compatible?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was automatically flagged by the shell linter you have in the repo, can’t see the exact message any more but I believe it mentioned that this has been supported in Bash for decades

done
IFS=$' \t\n'

Expand All @@ -37,6 +37,36 @@ scmpuff_status() {
}


# List git branches with numbered shortcuts
scmpuff_branch() {
local scmpuff_env_char="e"

if [ -n "$ZSH_VERSION" ]; then setopt shwordsplit; fi;

local cmd_output
cmd_output="$(/usr/bin/env scmpuff branch --branchlist "$@")"

local es=$?
if [ $es -ne 0 ]; then
return $es
fi

files="$(echo "$cmd_output" | head -n 1)"
scmpuff_clear_vars
IFS=$'\t'
local e=1
for file in $files; do
export $scmpuff_env_char$e="$file"
(( e++ ))
done
IFS=$' \t\n'

echo "$cmd_output" | tail -n +2

if [ -n "$ZSH_VERSION" ]; then unsetopt shwordsplit; fi;
}


# Clear numbered env variables
scmpuff_clear_vars() {
local scmpuff_env_char="e"
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"

"github.com/mroth/scmpuff/internal/commands/branch"
"github.com/mroth/scmpuff/internal/commands/exec"
"github.com/mroth/scmpuff/internal/commands/expand"
"github.com/mroth/scmpuff/internal/commands/inits"
Expand Down Expand Up @@ -44,6 +45,7 @@ func main() {
puffCmd.AddCommand(exec.CommandExec())
puffCmd.AddCommand(expand.CommandExpand())
puffCmd.AddCommand(status.CommandStatus())
puffCmd.AddCommand(branch.CommandBranch())

puffCmd.Execute()
}