Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ Inside the browser, **Enter** opens the highlighted project as a normal workspac
branch name: empty lets herdr generate `worktree/...`, bare names get the optional
`[worktree] branch_prefix`, and names containing `/` are used as-is.

Opening as a worktree fills its tabs from a matching
[worktree auto-layout](#worktree-auto-layout) — a file in `worktrees/` whose `repo`
matches — **not** the project's own `[[tabs]]`. Without a matching layout the
worktree opens with herdr's default single pane, so add a `worktrees/` file for any
repo you open this way.

A project is one TOML file in the `projects/` subdir of
[herdr-plus's config dir](#configuration). The file name doesn't matter; add a
file to add a project, delete it to remove it. With no files there, the browser
Expand Down
14 changes: 14 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,15 @@ import (
//go:embed examples
var embeddedExamples embed.FS

// PluginConfig holds herdr-plus's optional global settings, read from config.toml
// at the config root (alongside projects/ and quick-actions/). Every field is
// optional; an absent file yields the zero value.
type PluginConfig struct {
Worktree struct {
// BranchPrefix is prepended to a bare worktree branch name typed in the
// projects browser (ctrl+g). It is used verbatim, so include any trailing
// "/" yourself. Empty disables prefixing; a name that already contains "/"
// is left untouched (see resolveWorktreeBranch).
BranchPrefix string `toml:"branch_prefix"`
} `toml:"worktree"`
}
Expand Down Expand Up @@ -56,6 +63,9 @@ func configBaseDir() (string, error) {
return filepath.Join(home, ".config", "herdr-plus"), nil
}

// configFilePath returns the path to herdr-plus's optional global config file,
// config.toml at the config root (the same directory that holds projects/ and
// quick-actions/).
func configFilePath() (string, error) {
base, err := configBaseDir()
if err != nil {
Expand All @@ -64,6 +74,10 @@ func configFilePath() (string, error) {
return filepath.Join(base, "config.toml"), nil
}

// loadPluginConfig reads and parses config.toml, returning zero-value settings
// when the file is absent — the config is entirely optional. A malformed file is
// a real error, surfaced to the caller instead of being silently ignored, so a
// typo does not quietly drop a setting.
func loadPluginConfig() (PluginConfig, error) {
var cfg PluginConfig
path, err := configFilePath()
Expand Down
5 changes: 5 additions & 0 deletions herdr.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ func worktreeCreateParams(cwd, branch string, focus bool) map[string]any {
return params
}

// worktreeCreate asks herdr to create a git worktree from the repo at cwd, on the
// given branch (blank lets herdr generate a worktree/<name> branch), and — when
// focus is true — switch to its new workspace. herdr fires a worktree.created
// event, which the worktree auto-layout handler reacts to; we do not read the
// response, so the layout is applied by that handler rather than here.
func (c *herdrClient) worktreeCreate(cwd, branch string, focus bool) error {
return c.call("worktree.create", worktreeCreateParams(cwd, branch, focus), nil)
}
Expand Down
2 changes: 1 addition & 1 deletion herdr_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Date: 2026-06-15
// Date: 2026-07-05
// Author: Spicer Matthews (spicer@cloudmanic.com)
// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved.
//
Expand Down
35 changes: 32 additions & 3 deletions projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package main

import (
"errors"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -110,20 +111,48 @@ func openProject(client *herdrClient, p Project) error {
return layoutTabs(client, ws, rootTab, rootPane, p.Tabs)
}

// openProjectAsWorktree creates a git worktree from the project's working
// directory instead of opening the project as a normal workspace. It resolves and
// validates the directory, confirms it is inside a git work tree (worktrees
// require one — a clearer error than herdr's if not), and asks herdr to create the
// worktree, focused. The new workspace's tabs are not laid out here: herdr's
// worktree.created event drives the worktree auto-layout, so a matching file in
// worktrees/ fills the workspace. Without one the worktree opens bare — the
// project's own [[tabs]] are not used for this path.
func openProjectAsWorktree(client *herdrClient, p Project, branch string) error {
// filepath.Abs already returns an absolute path (or an error), so no separate
// IsAbs check is needed after it.
dir, err := filepath.Abs(p.expandedWorkingDir())
if err != nil {
return fmt.Errorf("resolve working directory: %w", err)
}
if !filepath.IsAbs(dir) {
return fmt.Errorf("working directory is not absolute: %s", dir)
}
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
return fmt.Errorf("working directory does not exist: %s", dir)
}
if !isInsideGitWorkTree(dir) {
return fmt.Errorf("not a git repository: %s (opening as a worktree requires one)", dir)
}
return client.worktreeCreate(dir, branch, true)
}

// isInsideGitWorkTree reports whether dir is inside a git work tree, so
// openProjectAsWorktree can give a clear error before asking herdr to create a
// worktree there. It shells out to git: when git runs and reports the directory is
// not a work tree it returns false, but when git cannot be run at all (not
// installed / not on PATH) it returns true, deferring the real check to herdr
// rather than blocking on git's absence.
func isInsideGitWorkTree(dir string) bool {
out, err := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree").Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return false // git ran and said this is not a work tree
}
return true // git could not run — let herdr make the call
}
return strings.TrimSpace(string(out)) == "true"
}

// layoutTabs lays an ordered list of tabs — each with its panes and optional
// startup commands — into an existing workspace whose root tab and root pane are
// rootTab and rootPane. tab[0] reuses the root tab (renamed) and root pane; each
Expand Down
33 changes: 33 additions & 0 deletions projects_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// Date: 2026-07-05
// Author: Spicer Matthews (spicer@cloudmanic.com)
// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved.
//

package main

import (
"os/exec"
"testing"
)

// TestIsInsideGitWorkTree confirms the pre-flight check openProjectAsWorktree uses
// before creating a worktree: a plain directory is not a work tree, while a
// git-initialized one is.
func TestIsInsideGitWorkTree(t *testing.T) {
// A fresh temp dir with no git repo is not inside a work tree.
plain := t.TempDir()
if isInsideGitWorkTree(plain) {
t.Fatalf("expected %s to not be inside a git work tree", plain)
}

// A git-initialized dir is. Skip (rather than fail) if git can't init here, so
// the suite still runs in an environment without a usable git.
repo := t.TempDir()
if out, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
t.Skipf("git init unavailable, skipping: %v (%s)", err, out)
}
if !isInsideGitWorkTree(repo) {
t.Fatalf("expected %s to be inside a git work tree", repo)
}
}
11 changes: 11 additions & 0 deletions projectsmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ func (m projectsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}

// updateBranch handles input while the worktree branch prompt is showing (the
// modeBranch state entered by ctrl+g). Enter confirms: it marks the choice as a
// worktree and resolves the typed name through the configured prefix, then quits
// so runProjectsUI opens it. Esc backs out to the list, clearing the pending
// choice. Every other key — and the cursor-blink tick — flows to the text input.
func (m projectsModel) updateBranch(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
Expand Down Expand Up @@ -321,6 +326,9 @@ func (m projectsModel) activateProject() (tea.Model, tea.Cmd) {
return m, tea.Quit
}

// promptWorktreeBranch switches the browser into its branch-entry mode for the
// highlighted project, resetting the input to empty and focusing it. It is the
// ctrl+g entry point; with nothing selectable it is a no-op.
func (m projectsModel) promptWorktreeBranch() (tea.Model, tea.Cmd) {
idx := m.list.selectedIndex()
if idx < 0 {
Expand Down Expand Up @@ -385,6 +393,9 @@ func (m projectsModel) browserView(w, h int) string {
return top + strings.Repeat("\n", gap) + bottom
}

// branchView renders the worktree branch prompt: the chosen project's name and
// working directory above a single-line input for the optional branch name, with
// a footer explaining enter/esc. It backs the modeBranch state.
func (m projectsModel) branchView(w, h int) string {
header := headerBarStyle.Width(w).Render(projectsTitle)

Expand Down
7 changes: 6 additions & 1 deletion worktreebranch.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Date: 2026-06-15
// Date: 2026-07-05
// Author: Spicer Matthews (spicer@cloudmanic.com)
// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved.
//
Expand All @@ -8,6 +8,11 @@ package main

import "strings"

// resolveWorktreeBranch turns the branch name typed in the projects browser into
// the branch handed to herdr. A blank input stays blank, letting herdr generate
// its native worktree/<name> branch. A name already containing "/" is treated as
// fully qualified and used as-is. Otherwise the optional prefix is prepended
// verbatim (the caller's config supplies any trailing "/").
func resolveWorktreeBranch(input, prefix string) string {
branch := strings.TrimSpace(input)
if branch == "" {
Expand Down
2 changes: 1 addition & 1 deletion worktreebranch_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Date: 2026-06-15
// Date: 2026-07-05
// Author: Spicer Matthews (spicer@cloudmanic.com)
// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved.
//
Expand Down
Loading