From af2d3385c7ceb58338c3d93988950b3620c1d79f Mon Sep 17 00:00:00 2001 From: Jan Smrcka Date: Mon, 23 Feb 2026 22:27:39 +0100 Subject: [PATCH 1/3] feat: add branch creation from branch picker (ctrl+n) --- internal/git/repo.go | 6 ++ internal/git/repo_test.go | 53 ++++++++++ internal/ui/model.go | 81 +++++++++++++++ internal/ui/model_test.go | 200 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+) diff --git a/internal/git/repo.go b/internal/git/repo.go index f9a6553..eaecd13 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -108,6 +108,12 @@ func (r *Repo) ListBranches() ([]string, error) { return strings.Split(out, "\n"), nil } +// CreateBranch creates a new branch at the current HEAD. +func (r *Repo) CreateBranch(name string) error { + _, err := r.run("branch", name) + return err +} + // CheckoutBranch switches to the named branch. func (r *Repo) CheckoutBranch(name string) error { _, err := r.run("switch", name) diff --git a/internal/git/repo_test.go b/internal/git/repo_test.go index 7a9f1f1..7fe46bd 100644 --- a/internal/git/repo_test.go +++ b/internal/git/repo_test.go @@ -578,3 +578,56 @@ func TestCommitDiffFiles(t *testing.T) { t.Errorf("Path=%q, want f.txt", files[0].Path) } } + +func TestCreateBranch(t *testing.T) { + t.Parallel() + repo := setupTestRepo(t) + addCommit(t, repo, "f.txt", "v1", "init") + + if err := repo.CreateBranch("feature-x"); err != nil { + t.Fatal(err) + } + branches, _ := repo.ListBranches() + found := false + for _, b := range branches { + if b == "feature-x" { + found = true + } + } + if !found { + t.Errorf("created branch not in list: %v", branches) + } +} + +func TestCreateBranch_AlreadyExists(t *testing.T) { + t.Parallel() + repo := setupTestRepo(t) + addCommit(t, repo, "f.txt", "v1", "init") + gitRun(t, repo.Dir(), "branch", "dup") + + err := repo.CreateBranch("dup") + if err == nil { + t.Error("expected error creating duplicate branch") + } +} + +func TestCreateBranch_InvalidName(t *testing.T) { + t.Parallel() + repo := setupTestRepo(t) + addCommit(t, repo, "f.txt", "v1", "init") + + err := repo.CreateBranch("bad..name") + if err == nil { + t.Error("expected error for invalid branch name") + } +} + +func TestCreateBranch_NoCommits(t *testing.T) { + t.Parallel() + repo := setupTestRepo(t) + + err := repo.CreateBranch("nope") + if err == nil { + t.Error("expected error creating branch in empty repo") + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 363dc17..2e9356e 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -104,6 +104,8 @@ type Model struct { branchOffset int currentBranch string branchFilter textinput.Model + branchCreating bool + branchInput textinput.Model // Push/pull state upstream git.UpstreamInfo @@ -137,6 +139,10 @@ func NewModel( bf.CharLimit = 100 bf.Width = fileListWidth - 8 + bi := textinput.New() + bi.Placeholder = "branch name..." + bi.CharLimit = 100 + return Model{ repo: repo, cfg: cfg, @@ -149,6 +155,7 @@ func NewModel( prevCurs: -1, commitInput: ti, branchFilter: bf, + branchInput: bi, } } @@ -229,6 +236,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleBranchesLoaded(msg) case branchSwitchedMsg: return m.handleBranchSwitched(msg) + case branchCreatedMsg: + return m.handleBranchCreated(msg) case upstreamStatusMsg: m.upstream = msg.info return m, nil @@ -393,6 +402,20 @@ func (m Model) handleBranchSwitched(msg branchSwitchedMsg) (tea.Model, tea.Cmd) return m, m.refreshFilesCmd() } +func (m Model) handleBranchCreated(msg branchCreatedMsg) (tea.Model, tea.Cmd) { + m.branchCreating = false + m.branchInput.Reset() + if msg.err != nil { + m.statusMsg = "create failed: " + msg.err.Error() + return m, nil + } + m.mode = modeFileList + m.statusMsg = "created & switched to " + msg.name + m.prevCurs = -1 + m.cursor = 0 + return m, m.refreshFilesCmd() +} + func (m Model) handlePushDone(msg pushDoneMsg) (tea.Model, tea.Cmd) { if msg.err != nil { m.statusMsg = "push failed: " + msg.err.Error() @@ -551,7 +574,17 @@ func (m Model) updateDiffMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Branch picker mode func (m Model) updateBranchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.branchCreating { + return m.updateBranchCreateMode(msg) + } + switch msg.String() { + case "ctrl+n": + m.branchCreating = true + m.branchInput.Reset() + m.branchInput.Focus() + m.branchFilter.Blur() + return m, textinput.Blink case "esc": if m.branchFilter.Value() != "" { m.branchFilter.Reset() @@ -607,6 +640,27 @@ func (m Model) updateBranchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +// Branch create sub-mode +func (m Model) updateBranchCreateMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "ctrl+c": + m.branchCreating = false + m.branchInput.Reset() + m.branchFilter.Focus() + return m, nil + case "enter": + name := strings.TrimSpace(m.branchInput.Value()) + if name == "" { + m.statusMsg = "empty branch name" + return m, nil + } + return m, m.createBranchCmd(name) + } + var cmd tea.Cmd + m.branchInput, cmd = m.branchInput.Update(msg) + return m, cmd +} + func (m Model) clampBranchScroll() Model { h := m.contentHeight() - 1 // -1 for filter bar if h <= 0 { @@ -778,6 +832,22 @@ func (m Model) buildRefreshedFiles() filesRefreshedMsg { return filesRefreshedMsg{files: buildFileItems(m.repo, files, untracked)} } +type branchCreatedMsg struct { + name string + err error +} + +func (m Model) createBranchCmd(name string) tea.Cmd { + repo := m.repo + return func() tea.Msg { + if err := repo.CreateBranch(name); err != nil { + return branchCreatedMsg{name: name, err: err} + } + err := repo.CheckoutBranch(name) + return branchCreatedMsg{name: name, err: err} + } +} + type savePrefDoneMsg struct{ err error } func (m Model) saveSplitPrefCmd() tea.Cmd { @@ -881,6 +951,9 @@ func (m Model) View() string { if m.mode == modeCommit { return lipgloss.JoinVertical(lipgloss.Left, header, main, statusBar, m.renderCommitBar()) } + if m.mode == modeBranchPicker && m.branchCreating { + return lipgloss.JoinVertical(lipgloss.Left, header, main, statusBar, m.renderBranchCreateBar()) + } helpBar := m.renderHelpBar() return lipgloss.JoinVertical(lipgloss.Left, header, main, statusBar, helpBar) } @@ -1096,6 +1169,7 @@ func (m Model) renderHelpBar() string { {"type", "filter"}, {"↑/↓/^j/^k", "navigate"}, {"enter", "switch"}, + {"^n", "new"}, {"esc", "clear/close"}, } default: @@ -1133,3 +1207,10 @@ func (m Model) renderCommitBar() string { esc := " " + m.styles.HelpDesc.Render("esc cancel · enter commit") return lipgloss.NewStyle().Width(m.width).Render(prompt + input + esc) } + +func (m Model) renderBranchCreateBar() string { + prompt := m.styles.HelpKey.Render(" new branch: ") + input := m.branchInput.View() + esc := " " + m.styles.HelpDesc.Render("esc cancel · enter create") + return lipgloss.NewStyle().Width(m.width).Render(prompt + input + esc) +} diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 24f17bb..8fdaa52 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -139,6 +139,9 @@ func newTestModel(t *testing.T, files []fileItem) Model { bf.Placeholder = "filter..." bf.CharLimit = 100 bf.Width = fileListWidth - 8 + bi := textinput.New() + bi.Placeholder = "branch name..." + bi.CharLimit = 100 return Model{ files: files, styles: NewStyles(th), @@ -148,6 +151,7 @@ func newTestModel(t *testing.T, files []fileItem) Model { height: 30, commitInput: textinput.New(), branchFilter: bf, + branchInput: bi, } } @@ -554,3 +558,199 @@ func TestRenderBranchList_NoMatches(t *testing.T) { t.Error("should show 0/2 count") } } + +func TestUpdateBranchMode_CtrlN_EntersCreateMode(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branches = []string{"main", "dev"} + m.branchCursor = 0 + m.branchFilter.Focus() + + result, cmd := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyCtrlN}) + rm := result.(Model) + if !rm.branchCreating { + t.Error("ctrl+n should set branchCreating=true") + } + if rm.mode != modeBranchPicker { + t.Error("should stay in branch picker mode") + } + if cmd == nil { + t.Error("expected textinput.Blink cmd") + } +} + +func TestUpdateBranchMode_CreateMode_RoutesToInput(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + m.branchInput.Focus() + m.branches = []string{"main"} + + // Typing 'j' should go to text input, not move branch cursor + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + rm := result.(Model) + if rm.branchInput.Value() != "j" { + t.Errorf("input=%q, want %q", rm.branchInput.Value(), "j") + } +} + +func TestUpdateBranchMode_CreateMode_Esc_Cancels(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + m.branchInput.Focus() + m.branchInput.SetValue("feature-x") + m.branches = []string{"main"} + + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyEscape}) + rm := result.(Model) + if rm.branchCreating { + t.Error("esc should cancel branch creation") + } + if rm.branchInput.Value() != "" { + t.Error("input should be reset on cancel") + } +} + +func TestUpdateBranchMode_CreateMode_CtrlC_Cancels(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + m.branchInput.Focus() + m.branches = []string{"main"} + + result, _ := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyCtrlC}) + rm := result.(Model) + if rm.branchCreating { + t.Error("ctrl+c should cancel branch creation, not quit") + } +} + +func TestUpdateBranchMode_CreateMode_Enter_EmptyName(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + m.branchInput.Focus() + m.branches = []string{"main"} + + result, cmd := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyEnter}) + rm := result.(Model) + if !strings.Contains(rm.statusMsg, "empty") { + t.Errorf("statusMsg=%q, want empty branch name error", rm.statusMsg) + } + if cmd != nil { + t.Error("should not issue cmd on empty name") + } +} + +func TestUpdateBranchMode_CreateMode_Enter_SubmitsCmd(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + m.branchInput.Focus() + m.branchInput.SetValue("feature-x") + m.branches = []string{"main"} + + _, cmd := m.updateBranchMode(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Error("expected async create branch cmd") + } +} + +func TestHandleBranchCreated_Success(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + + result, cmd := m.handleBranchCreated(branchCreatedMsg{name: "feature-x"}) + rm := result.(Model) + if rm.mode != modeFileList { + t.Errorf("mode=%d, want modeFileList", rm.mode) + } + if rm.branchCreating { + t.Error("branchCreating should be false") + } + if !strings.Contains(rm.statusMsg, "feature-x") { + t.Errorf("statusMsg=%q, want branch name", rm.statusMsg) + } + if cmd == nil { + t.Error("expected refresh files cmd") + } +} + +func TestHandleBranchCreated_Error(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + + result, cmd := m.handleBranchCreated(branchCreatedMsg{ + name: "bad", + err: fmt.Errorf("already exists"), + }) + rm := result.(Model) + if rm.mode != modeBranchPicker { + t.Error("should stay in branch picker on error") + } + if !strings.Contains(rm.statusMsg, "already exists") { + t.Errorf("statusMsg=%q, want error", rm.statusMsg) + } + if cmd != nil { + t.Error("should not issue cmd on error") + } +} + +func TestRenderHelpBar_BranchMode_ShowsNewKey(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeBranchPicker + bar := m.renderHelpBar() + if !strings.Contains(bar, "^n") { + t.Error("branch help should contain ^n for new branch") + } + if !strings.Contains(bar, "new") { + t.Error("branch help should contain 'new' description") + } +} + +func TestRenderBranchCreateBar(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.branchCreating = true + m.branchInput.Focus() + bar := m.renderBranchCreateBar() + if !strings.Contains(bar, "branch") { + t.Error("create bar should contain 'branch' prompt") + } + if !strings.Contains(bar, "esc") { + t.Error("create bar should show esc hint") + } + if !strings.Contains(bar, "enter") { + t.Error("create bar should show enter hint") + } +} + +func TestView_BranchCreating_ShowsCreateBar(t *testing.T) { + t.Parallel() + // View() calls renderHeader() which needs a real repo for BranchName() + // Use renderBranchCreateBar() directly to test view integration + m := newTestModel(t, nil) + m.mode = modeBranchPicker + m.branchCreating = true + m.branchInput.Focus() + + bar := m.renderBranchCreateBar() + if !strings.Contains(bar, "new branch") { + t.Error("create bar should show 'new branch' prompt") + } + if !strings.Contains(bar, "enter create") { + t.Error("create bar should show 'enter create' hint") + } +} From c4f605e147133b4539e3a48039e1bad1213768fd Mon Sep 17 00:00:00 2001 From: Jan Smrcka Date: Mon, 23 Feb 2026 22:31:43 +0100 Subject: [PATCH 2/3] feat: push --set-upstream origin when no upstream configured --- internal/git/repo.go | 6 ++++++ internal/git/repo_test.go | 32 +++++++++++++++++++++++++++++ internal/ui/model.go | 21 ++++++++++++++++++- internal/ui/model_test.go | 43 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/internal/git/repo.go b/internal/git/repo.go index eaecd13..9593f7f 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -148,6 +148,12 @@ func (r *Repo) Push() error { return err } +// PushSetUpstream pushes and sets the upstream tracking branch. +func (r *Repo) PushSetUpstream(remote, branch string) error { + _, err := r.runWithStderr("push", "--set-upstream", remote, branch) + return err +} + // Pull pulls from the upstream branch using fast-forward only. func (r *Repo) Pull() error { _, err := r.runWithStderr("pull", "--ff-only") diff --git a/internal/git/repo_test.go b/internal/git/repo_test.go index 7fe46bd..d3ea6bf 100644 --- a/internal/git/repo_test.go +++ b/internal/git/repo_test.go @@ -631,3 +631,35 @@ func TestCreateBranch_NoCommits(t *testing.T) { t.Error("expected error creating branch in empty repo") } } + +func TestPushSetUpstream(t *testing.T) { + t.Parallel() + // Create a bare "remote" repo + bare := t.TempDir() + cmd := exec.Command("git", "init", "--bare", bare) + cmd.Env = gitEnv(t.TempDir()) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("bare init: %v\n%s", err, out) + } + + repo := setupTestRepo(t) + addCommit(t, repo, "f.txt", "v1", "init") + gitRun(t, repo.Dir(), "remote", "add", "origin", bare) + + // No upstream yet + info := repo.UpstreamStatus() + if info.Upstream != "" { + t.Fatalf("expected no upstream, got %q", info.Upstream) + } + + // Push with set-upstream + if err := repo.PushSetUpstream("origin", "master"); err != nil { + t.Fatal(err) + } + + // Now upstream should be configured + info = repo.UpstreamStatus() + if info.Upstream == "" { + t.Error("upstream should be configured after PushSetUpstream") + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 2e9356e..4a86565 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -448,6 +448,17 @@ func (m Model) pushCmd() tea.Cmd { } } +func (m Model) pushSetUpstreamCmd() tea.Cmd { + repo := m.repo + branch := m.currentBranch + if branch == "" { + branch = repo.BranchName() + } + return func() tea.Msg { + return pushDoneMsg{err: repo.PushSetUpstream("origin", branch)} + } +} + func (m Model) pullCmd() tea.Cmd { repo := m.repo return func() tea.Msg { @@ -477,10 +488,18 @@ func (m Model) updateFileListMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.pushConfirm { m.pushConfirm = false m.statusMsg = "pushing..." + if m.upstream.Upstream == "" { + return m, m.pushSetUpstreamCmd() + } return m, m.pushCmd() } if m.upstream.Upstream == "" { - m.statusMsg = "no upstream configured" + branch := m.currentBranch + if branch == "" { + branch = m.repo.BranchName() + } + m.pushConfirm = true + m.statusMsg = "press P again to push --set-upstream origin " + branch return m, nil } m.pushConfirm = true diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 8fdaa52..6ca5bdf 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -754,3 +754,46 @@ func TestView_BranchCreating_ShowsCreateBar(t *testing.T) { t.Error("create bar should show 'enter create' hint") } } + +func TestPush_NoUpstream_OffersSetUpstream(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeFileList + m.upstream = git.UpstreamInfo{} // no upstream + m.currentBranch = "feature-x" + + // First P should offer set-upstream, not block + result, _ := m.updateFileListMode(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'P'}}) + rm := result.(Model) + if !strings.Contains(rm.statusMsg, "set-upstream") { + t.Errorf("statusMsg=%q, should mention set-upstream", rm.statusMsg) + } + if !rm.pushConfirm { + t.Error("should enter push confirm state") + } + if !strings.Contains(rm.statusMsg, "feature-x") { + t.Errorf("statusMsg=%q, should mention branch name", rm.statusMsg) + } +} + +func TestPush_NoUpstream_ConfirmPushes(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeFileList + m.upstream = git.UpstreamInfo{} // no upstream + m.pushConfirm = true + m.currentBranch = "feature-x" + + // Second P should issue a push cmd + result, cmd := m.updateFileListMode(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'P'}}) + rm := result.(Model) + if rm.pushConfirm { + t.Error("pushConfirm should be cleared") + } + if !strings.Contains(rm.statusMsg, "pushing") { + t.Errorf("statusMsg=%q, should say pushing", rm.statusMsg) + } + if cmd == nil { + t.Error("expected push cmd") + } +} From 9f74dfd5cb8524b72ece8079b14f04e038df7d05 Mon Sep 17 00:00:00 2001 From: Jan Smrcka Date: Mon, 23 Feb 2026 22:33:03 +0100 Subject: [PATCH 3/3] docs: update README with branch creation and push --set-upstream Co-Authored-By: Claude Opus 4.6 --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f8b29bb..91d5a94 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ differ commit # review staged + commit | `b` | open branch picker | | `v` | toggle split (side-by-side) diff | | `e` | open in editor (`$EDITOR`, configurable) | -| `P` | push (press twice to confirm) | +| `P` | push (auto `--set-upstream` if needed) | | `F` | pull (fast-forward only) | | `g/G` | first/last file | | `q` | quit | @@ -74,11 +74,13 @@ differ commit # review staged + commit ### Branch Picker -| Key | Action | -| ------- | ------------- | -| `j/k` | navigate | -| `enter` | switch branch | -| `esc/b` | cancel | +| Key | Action | +| -------------- | ------------------------- | +| type | filter branches | +| `↑/↓` / `^j/^k` | navigate | +| `enter` | switch branch | +| `ctrl+n` | create new branch | +| `esc` | clear filter / close | ## AI Commit Messages @@ -125,8 +127,9 @@ Press `prefix + g` to open differ in a floating window over your current session - Staged/unstaged/untracked file indicators - Stage/unstage individual files or all at once - Split (side-by-side) diff view -- Branch picker with keyboard navigation -- Push/pull with upstream ahead/behind tracking +- Branch picker with type-to-filter and branch creation (`ctrl+n`) +- Push with auto `--set-upstream` for new branches +- Pull with upstream ahead/behind tracking - Per-file added/deleted line counts in file list - Configurable editor command (`editor_cmd`) - Commit flow with AI-generated messages