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
3 changes: 1 addition & 2 deletions .claude/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
- `list <folder>` — list all branches in a folder
- `increment [folder]` — create and check out the next numbered branch; errors if not on the max branch when folder is inferred
- `squash` — increment + squash all commits since trunk divergence into one
- `delete [--force] <folder>` — delete all branches in folder (with confirmation)
- `delete-upto [--force] <folder> <n>` — delete numeric branches below n
- `delete [--force] [--upto <n>] <folder>` — delete branches in folder (with confirmation); `--upto <n>` deletes only numeric branches below n
- `rename [--force] <old> <new>` — rename a folder prefix

**Plumbing:**
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async/temp
I know I can safely get rid of most of those now, so I do:

```
$ git folder delete-upto async 4
$ git folder delete --upto 4 async
keep:
async/4
async/5
Expand Down
3 changes: 1 addition & 2 deletions cmd/git-folder/USAGE.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
usage: git folder list <folder>
or: git folder increment [<folder>]
or: git folder delete [--force] <folder>
or: git folder delete-upto [--force] <folder> <n>
or: git folder delete [--force] [--upto <n>] [<folder>]
or: git folder squash
or: git folder rename [--force] <old> <new>
or: git folder max [branch|number] [<folder>]
Expand Down
127 changes: 55 additions & 72 deletions cmd/git-folder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,6 @@ func main() {
err = cmdMax(args[1:])
case "delete":
err = cmdDelete(args[1:])
case "delete-upto":
err = cmdDeleteUpto(args[1:])
case "squash":
err = cmdSquash()
case "rename":
Expand Down Expand Up @@ -308,7 +306,27 @@ func cmdIncrement(args []string) error {
}

func cmdDelete(args []string) error {
name, err := resolveFolder(args)
var uptoSet bool
var upto float64
var rest []string
for i := 0; i < len(args); i++ {
if args[i] == "--upto" {
if i+1 >= len(args) {
return fmt.Errorf("--upto requires a number")
}
n, err := strconv.ParseFloat(args[i+1], 64)
if err != nil {
return fmt.Errorf("invalid --upto number: %s", args[i+1])
}
upto = n
uptoSet = true
i++
} else {
rest = append(rest, args[i])
}
}

Comment thread
claybridges marked this conversation as resolved.
name, err := resolveFolder(rest)
if err != nil {
return err
}
Expand All @@ -321,81 +339,43 @@ func cmdDelete(args []string) error {
return fmt.Errorf("no branches in folder %s/", name)
}

detach, err := branchesPreflight(branches)
if err != nil {
return err
}

fmt.Printf("delete all branches in folder %s/:\n", name)
for _, b := range branches {
fmt.Printf(" %s\n", b)
}

if !confirm("confirm? [yN] ") {
fmt.Println("aborted")
return nil
}

if detach != "" {
if err := detachHEAD(detach); err != nil {
return err
var toKeep, toDelete []string
if uptoSet {
for _, b := range branches {
num, ok := folder.NumberFloat(b)
if ok && num < upto {
toDelete = append(toDelete, b)
} else {
toKeep = append(toKeep, b)
}
}
}
for _, b := range branches {
if err := gitExec("branch", "-D", b); err != nil {
return fmt.Errorf("failed to delete %s: %w", b, err)
if len(toDelete) == 0 {
return fmt.Errorf("no numbered branches below %v in folder %s/", upto, name)
}
}
return nil
}

func cmdDeleteUpto(args []string) error {
if len(args) != 2 {
return fmt.Errorf("usage: git folder delete-upto <folder> <n>")
}

folderName := args[0]
if err := validateFolder(folderName); err != nil {
return err
}
n, err := strconv.ParseFloat(args[1], 64)
if err != nil {
return fmt.Errorf("invalid number: %s", args[1])
}

branches, err := folder.Enumerate(folderName)
if err != nil {
return err
}

var toKeep []string
var toDelete []string
for _, b := range branches {
num, ok := folder.NumberFloat(b)
if ok && num < n {
toDelete = append(toDelete, b)
} else {
toKeep = append(toKeep, b)
}
}

if len(toDelete) == 0 {
return fmt.Errorf("no numbered branches below %v in folder %s/", n, folderName)
} else {
toDelete = branches
}

detach, err := branchesPreflight(toDelete)
if err != nil {
return err
}

fmt.Printf("keep:\n")
for _, b := range toKeep {
fmt.Printf(" %s\n", b)
}
fmt.Println()
fmt.Printf("delete:\n")
for _, b := range toDelete {
fmt.Printf(" %s\n", b)
if uptoSet {
fmt.Printf("keep:\n")
for _, b := range toKeep {
fmt.Printf(" %s\n", b)
}
fmt.Println()
fmt.Printf("delete:\n")
for _, b := range toDelete {
fmt.Printf(" %s\n", b)
}
} else {
fmt.Printf("delete all branches in folder %s/:\n", name)
for _, b := range toDelete {
fmt.Printf(" %s\n", b)
}
}

if !confirm("confirm? [yN] ") {
Expand Down Expand Up @@ -568,8 +548,7 @@ _git-folder() {
'list:list branches in a folder'
'max:print max branch or number'
'increment:create next numbered branch'
'delete:delete all branches in a folder'
'delete-upto:delete numbered branches below n'
'delete:delete all branches in a folder (use --upto N to keep N+)'
'squash:increment and squash commits'
'rename:rename a folder prefix'
'version:show version'
Expand All @@ -585,8 +564,12 @@ _git-folder() {
_describe 'command' commands
;;
args)
# After --upto, expect a numeric threshold; don't suggest folders.
if [[ ${words[CURRENT-1]} == "--upto" ]]; then
return
fi
case $words[1] in
list|delete|delete-upto|increment|rename)
list|delete|increment|rename)
_git-folder-folders
Comment thread
claybridges marked this conversation as resolved.
;;
esac
Expand Down
37 changes: 17 additions & 20 deletions cmd/git-folder/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,19 +418,19 @@ func initAsyncRepo(t *testing.T) string {
return dir
}

// --- cmdDeleteUpto ---
// --- cmdDelete --upto ---

func TestCmdDeleteUptoReadmeExample(t *testing.T) {
dir := initAsyncRepo(t)

deleted := withFolder("async", "1", "2", "2.5", "3")
assertBranchesExist(t, dir, deleted)

cmd := exec.Command(binaryPath, "delete-upto", "async", "4")
cmd := exec.Command(binaryPath, "delete", "--upto", "4", "async")
cmd.Dir = dir
cmd.Stdin = strings.NewReader("y\n")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("delete-upto failed: %v\n%s", err, out)
t.Fatalf("delete --upto failed: %v\n%s", err, out)
}

kept := branchList(t, dir, "async/*")
Expand All @@ -454,7 +454,7 @@ func TestCmdDeleteUptoConfirm(t *testing.T) {

withStdin(t, "y\n")

err := cmdDeleteUpto([]string{"x", "3"})
err := cmdDelete([]string{"--upto", "3", "x"})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -484,7 +484,7 @@ func TestCmdDeleteUptoFloatThreshold(t *testing.T) {

withStdin(t, "y\n")

err := cmdDeleteUpto([]string{"x", "2.5"})
err := cmdDelete([]string{"--upto", "2.5", "x"})
if err != nil {
t.Fatal(err)
}
Expand All @@ -510,7 +510,7 @@ func TestCmdDeleteUptoAbort(t *testing.T) {

withStdin(t, "n\n")

err := cmdDeleteUpto([]string{"y", "2"})
err := cmdDelete([]string{"--upto", "2", "y"})
if err != nil {
t.Fatal(err)
}
Expand All @@ -527,27 +527,24 @@ func TestCmdDeleteUptoNoneBelow(t *testing.T) {

run(t, dir, "git", "branch", "z/5")

err := cmdDeleteUpto([]string{"z", "1"})
err := cmdDelete([]string{"--upto", "1", "z"})
if err == nil {
t.Fatal("expected error when no branches below n")
}
}

func TestCmdDeleteUptoBadArgs(t *testing.T) {
if err := cmdDeleteUpto(nil); err == nil {
t.Fatal("expected error for no args")
}
if err := cmdDeleteUpto([]string{"a"}); err == nil {
t.Fatal("expected error for one arg")
if err := cmdDelete([]string{"--upto"}); err == nil {
t.Fatal("expected error for missing upto value")
}
if err := cmdDeleteUpto([]string{"a", "notanumber"}); err == nil {
t.Fatal("expected error for non-numeric arg")
if err := cmdDelete([]string{"--upto", "notanumber", "a"}); err == nil {
t.Fatal("expected error for non-numeric upto")
}
}

func TestCmdDeleteUptoNotARepo(t *testing.T) {
inNonRepo(t)
if err := cmdDeleteUpto([]string{"foo", "3"}); err == nil {
if err := cmdDelete([]string{"--upto", "3", "foo"}); err == nil {
t.Fatal("expected error outside git repo")
}
}
Expand Down Expand Up @@ -869,7 +866,7 @@ func TestForceFlag(t *testing.T) {
forceFlag = false
})

t.Run("delete-upto with --force", func(t *testing.T) {
t.Run("delete --upto with --force", func(t *testing.T) {
dir := initTestRepo(t)
inDir(t, dir)
run(t, dir, "git", "checkout", "-b", "upto/1")
Expand All @@ -878,12 +875,12 @@ func TestForceFlag(t *testing.T) {
run(t, dir, "git", "checkout", "main")

forceFlag = false
args := parseGlobalFlags([]string{"--force", "delete-upto", "upto", "3"})
args := parseGlobalFlags([]string{"--force", "delete", "--upto", "3", "upto"})
if !forceFlag {
t.Fatal("--force flag not parsed")
}

err := cmdDeleteUpto(args[1:])
err := cmdDelete(args[1:])
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -989,7 +986,7 @@ func TestDeleteUptoCheckedOutBranch(t *testing.T) {
run(t, dir, "git", "checkout", "test/1")

forceFlag = false
err := cmdDeleteUpto([]string{"test", "3"})
err := cmdDelete([]string{"--upto", "3", "test"})
if err == nil {
t.Fatal("expected error when deleting checked-out branch")
}
Expand All @@ -1014,7 +1011,7 @@ func TestDeleteUptoCheckedOutBranch(t *testing.T) {

forceFlag = true
defer func() { forceFlag = false }()
err := cmdDeleteUpto([]string{"test", "3"})
err := cmdDelete([]string{"--upto", "3", "test"})
if err != nil {
t.Fatalf("unexpected error with --force: %v", err)
}
Expand Down
35 changes: 15 additions & 20 deletions git-folder.1
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ git\-folder \- manage groups of git branches as folders
git folder list <folder>
git folder increment [<folder>]
git folder squash
git folder delete [\-\-force] <folder>
git folder delete\-upto [\-\-force] <folder> <n>
git folder delete [\-\-force] [\-\-upto <n>] [<folder>]
git folder rename [\-\-force] <old> <new>
git folder max branch <folder>
git folder max number <folder>
git folder max [branch|number] [<folder>]
.EE
.SH DESCRIPTION
\f[B]git\-folder\f[R] manages groups of related branches under a common
Expand All @@ -39,28 +37,25 @@ Detects the trunk branch automatically.
Errors if the current branch is not a folder branch or not the max
branch.
.TP
\f[B]delete\f[R] \f[I]<folder>\f[R]
\f[B]delete\f[R] [\f[B]\(enupto\f[R] \f[I]<n>\f[R]] \f[I][<folder>]\f[R]
Delete all branches in the folder.
Prompts for confirmation unless \f[CR]\-\-force\f[R] is set.
.TP
\f[B]delete\-upto\f[R] \f[I]<folder>\f[R] \f[I]<n>\f[R]
Delete all numeric branches in the folder with suffix less than
\f[I]n\f[R].
Non\-numeric branches (e.g.\ \f[CR]async/temp\f[R]) are preserved.
If \f[I]folder\f[R] is omitted, infers from the current branch.
With \f[B]\(enupto\f[R] \f[I]<n>\f[R], delete only numeric branches with
suffix less than \f[I]n\f[R]; non\-numeric branches (e.g.
\f[CR]async/temp\f[R]) are preserved.
Prompts for confirmation unless \f[CR]\-\-force\f[R] is set.
.TP
\f[B]rename\f[R] \f[I]<old>\f[R] \f[I]<new>\f[R]
Rename all branches from prefix \f[I]old\f[R] to \f[I]new\f[R].
Prompts for confirmation unless \f[CR]\-\-force\f[R] is set.
.SH LOW\-LEVEL COMMANDS (PLUMBING)
.TP
\f[B]max branch\f[R] \f[I]<folder>\f[R]
Print the full name of the max branch in the folder
(e.g.\ \f[CR]async/4\f[R]).
.TP
\f[B]max number\f[R] \f[I]<folder>\f[R]
Print the numeric suffix of the max branch in the folder
\f[B]max\f[R] [\f[B]branch\f[R]|\f[B]number\f[R]] [\f[I]<folder>\f[R]]
Print the max branch in the folder.
With \f[B]branch\f[R], prints the full name (e.g.\ \f[CR]async/4\f[R]).
With \f[B]number\f[R] (the default), prints the numeric suffix
(e.g.\ \f[CR]4\f[R]).
If \f[I]folder\f[R] is omitted, infers from the current branch.
.SH OPTIONS
.TP
\f[B]\(enforce\f[R], \f[B]\-f\f[R]
Expand All @@ -76,8 +71,8 @@ If the current branch contains no \f[CR]/\f[R], the command fails.
Numeric branch suffixes may be integers or decimals (e.g.\ \f[CR]2\f[R],
\f[CR]2.5\f[R]).
Non\-numeric suffixes (e.g.\ \f[CR]temp\f[R], \f[CR]bigbooty\f[R]) are
treated as opaque and ignored by \f[B]increment\f[R] and
\f[B]delete\-upto\f[R].
treated as opaque and ignored by \f[B]increment\f[R] and \f[B]delete
\(enupto\f[R].
.SH COMPLETION
zsh completion is available via:
.IP
Expand Down Expand Up @@ -111,7 +106,7 @@ async/temp
Delete old branches, keeping from 4 onward:
.IP
.EX
$ git folder delete\-upto async 4
$ git folder delete \-\-upto 4 async
keep:
async/4
async/bigbooty
Expand Down
Loading