diff --git a/README.md b/README.md index d7c0bed..c0b6399 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.go b/config.go index 1a2788a..79cba82 100644 --- a/config.go +++ b/config.go @@ -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"` } @@ -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 { @@ -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() diff --git a/herdr.go b/herdr.go index cd3d27e..93cf328 100644 --- a/herdr.go +++ b/herdr.go @@ -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/ 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) } diff --git a/herdr_test.go b/herdr_test.go index 2ec552b..345cb91 100644 --- a/herdr_test.go +++ b/herdr_test.go @@ -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. // diff --git a/projects.go b/projects.go index b95ab19..effbf95 100644 --- a/projects.go +++ b/projects.go @@ -7,6 +7,7 @@ package main import ( + "errors" "fmt" "os" "os/exec" @@ -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 diff --git a/projects_test.go b/projects_test.go new file mode 100644 index 0000000..d8f57dc --- /dev/null +++ b/projects_test.go @@ -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) + } +} diff --git a/projectsmodel.go b/projectsmodel.go index 9aa036f..82b7506 100644 --- a/projectsmodel.go +++ b/projectsmodel.go @@ -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: @@ -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 { @@ -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) diff --git a/worktreebranch.go b/worktreebranch.go index 72a492e..7ce7880 100644 --- a/worktreebranch.go +++ b/worktreebranch.go @@ -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. // @@ -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/ 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 == "" { diff --git a/worktreebranch_test.go b/worktreebranch_test.go index f69b6bb..874d352 100644 --- a/worktreebranch_test.go +++ b/worktreebranch_test.go @@ -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. //