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
44 changes: 30 additions & 14 deletions cli/internal/cmd/lane.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package cmd

import (
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"

"github.com/mlorentedev/dotfiles/cli/internal/env"
"github.com/mlorentedev/dotfiles/cli/internal/lane"
)

Expand Down Expand Up @@ -38,10 +40,9 @@ func newLaneExecCmd() *cobra.Command {
"hands it the prompt as one argument, so no shell quoting is involved and the\n" +
"same call works from zsh, bash and PowerShell. An agent dotf has no launch\n" +
"command for is refused, naming the ones it has.",
Example: " dotf lane exec --agent claude --prompt-file prompt.md",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
Example: " dotf lane exec --agent claude --prompt-file prompt.md",
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(_ *cobra.Command, _ []string) error {
b, err := os.ReadFile(promptFile)
if err != nil {
Expand Down Expand Up @@ -73,11 +74,11 @@ func newLaneNextCmd() *cobra.Command {
"The agent is the one running in the calling pane unless --agent says\n" +
"otherwise. It never removes a worktree, the caller's included, and a second\n" +
"run for the same issue starts nothing. With no backend it prints the\n" +
"commands to run instead.",
Example: " dotf lane next\n dotf lane next --agent pi --dry-run",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
"commands to run instead, with the agent left for the person to pick when\n" +
"--agent does not name one.",
Example: " dotf lane next\n dotf lane next --agent pi --dry-run",
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(c *cobra.Command, _ []string) error {
return runLaneNext(c, agent, dryRun)
},
Expand Down Expand Up @@ -110,12 +111,12 @@ func runLaneNext(c *cobra.Command, agent string, dryRun bool) error {
return fmt.Errorf("lane next: prompt template: %w", err)
}
parent := filepath.Dir(common)
self, _ := os.Executable() // empty falls back to `dotf` on the pane's PATH
opts := lane.Options{
Agent: agent, DryRun: dryRun, Config: cfg, Template: string(tmpl),
Parent: parent, PromptDir: filepath.Join(common, "dotf-lane"), Dotf: self, GitDir: common,
Parent: parent, PromptDir: filepath.Join(common, "dotf-lane"), Dotf: selfBinary(c), GitDir: common,
WorktreeRoot: env.ResolvePath("WORKTREE_ROOT"),
Fetch: func() error {
remote, branch, _ := strings.Cut(cfg.Base, "/")
remote, branch := cfg.Remote()
_, err := laneRun("git", "-C", parent, "fetch", "--quiet", remote, branch)
return err
},
Expand All @@ -128,7 +129,11 @@ func runLaneNext(c *cobra.Command, agent string, dryRun bool) error {
if err != nil {
return err
}
w := c.OutOrStdout()
printOutcome(c.OutOrStdout(), out)
return nil
}

func printOutcome(w io.Writer, out lane.Outcome) {
if out.Issue.Number != 0 {
_, _ = fmt.Fprintf(w, "next: #%d %s -> %s (%s)\n", out.Issue.Number, out.Issue.Title, out.Branch, out.Agent)
}
Expand All @@ -141,5 +146,16 @@ func runLaneNext(c *cobra.Command, agent string, dryRun bool) error {
if out.Started {
_, _ = fmt.Fprintln(w, "started the successor")
}
return nil
}

// selfBinary is the path of the running dotf, which the successor is told to
// run. An empty answer falls back to `dotf` on the pane's PATH, which may be a
// different release: say so rather than start it silently.
func selfBinary(c *cobra.Command) string {
self, err := os.Executable()
if err != nil {
_, _ = fmt.Fprintf(c.ErrOrStderr(), "lane next: cannot locate this binary (%v); the successor runs `dotf` from the pane's PATH\n", err)
return ""
}
return self
}
Comment on lines +151 to 161

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Possible Issue

THEORETICAL: selfBinary prints a warning on stderr when os.Executable() fails, then returns "" so the successor runs dotf from PATH. The warning is emitted during option construction for every run, including dry runs, so it appears even when nothing is started. Only triggers when os.Executable errors, which is rare.

30 changes: 30 additions & 0 deletions cli/internal/cmd/lane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,33 @@ func TestLaneNext_DryRunNamesTheNextIssueAndChangesNothing(t *testing.T) {
}
}
}

// A refusal reaches the terminal: with the error silenced, a successor's pane
// died at a blank prompt (exit 1, nothing printed). The text a person needs is
// the one the error carries, so assert it on stderr and not only in the return.
func TestLane_RefusalsAreShownOnStderr(t *testing.T) {
f := filepath.Join(t.TempDir(), "p.md")
if err := os.WriteFile(f, []byte("go"), 0o644); err != nil {
t.Fatal(err)
}
for name, tc := range map[string]struct {
args []string
want string
}{
"unknown agent": {[]string{"exec", "--agent", "copilot", "--prompt-file", f}, `no launch command for agent "copilot"`},
"known agents listed": {[]string{"exec", "--agent", "copilot", "--prompt-file", f}, "agy, claude, pi"},
"missing prompt": {[]string{"exec", "--agent", "claude", "--prompt-file", f + ".missing"}, "lane exec:"},
} {
root := New("test", "test")
var stderr strings.Builder
root.SetOut(new(strings.Builder))
root.SetErr(&stderr)
root.SetArgs(append([]string{"lane"}, tc.args...))
if err := root.Execute(); err == nil {
t.Fatalf("%s: want an error", name)
}
if !strings.Contains(stderr.String(), tc.want) {
t.Errorf("%s: stderr = %q, want it to contain %q", name, stderr.String(), tc.want)
}
}
}
146 changes: 104 additions & 42 deletions cli/internal/lane/handoff.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package lane

import (
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -9,6 +10,8 @@ import (
"strconv"
"strings"
"time"

"github.com/mlorentedev/dotfiles/cli/internal/worktree"
)

// Options is one hand-off: which repo, which queue, which agent.
Expand All @@ -19,7 +22,11 @@ type Options struct {
Template string // the prompt template's text
Parent string // the repository's parent checkout
PromptDir string // where the rendered prompt is left for the successor
Fetch func() error
// WorktreeRoot is where `dotf worktree add` and herdr put a branch's
// worktree. Only the commands printed for a person need it: a backend
// decides the path itself.
WorktreeRoot string
Fetch func() error
// Dotf is the command word the successor's pane runs `lane exec` with. The
// caller passes the path of the binary that decided, so the successor is the
// same dotf and not whichever release `dotf` resolves to on the pane's PATH.
Expand Down Expand Up @@ -64,32 +71,12 @@ func Handoff(o Options, b Backend, src IssueSource) (Outcome, error) {
}
out := Outcome{Issue: issue, Branch: o.Config.BranchPrefix + "/" + Slug(issue.Title)}

prompt, err := RenderPrompt(o.Template, PromptData{
Number: issue.Number, Title: issue.Title, Branch: out.Branch, Remaining: remaining(o.Config.Queue, issue.Number),
})
p, err := prepare(o, b, &out)
if err != nil {
return out, err
}
if out.Agent, err = resolveAgent(o.Agent, b); err != nil {
return out, err
}
if _, err := LaunchArgv(out.Agent, prompt); err != nil {
return out, err
}
promptFile := filepath.Join(o.PromptDir, strconv.Itoa(issue.Number)+".md")
if !plainWord.MatchString(promptFile) {
return out, fmt.Errorf("lane: the prompt path %q has characters a pane's shell would read differently; use a PromptDir of letters, digits and _ . / : \\ ~ -", promptFile)
}
dotf := o.Dotf
if dotf == "" || !plainWord.MatchString(dotf) {
dotf = "dotf"
}
exec := fmt.Sprintf("%s lane exec --agent %s --prompt-file %s", dotf, out.Agent, promptFile)

if b == nil {
out.Commands = manualCommands(o, out, exec)
out.Note = "no session backend found; run these commands"
return out, writePrompt(o, promptFile, prompt)
return noBackend(o, out, p)
}
wt, found, err := b.Find(o.Parent, out.Branch)
if err != nil {
Expand All @@ -103,24 +90,88 @@ func Handoff(o Options, b Backend, src IssueSource) (Outcome, error) {
return out, ensureLease(o, wt.Path, issue.Number, out.Agent)
}
if o.DryRun {
out.Commands = []string{"(write the prompt to " + promptFile + ")", "(" + describe(found) + " " + out.Branch + ")", exec}
out.Commands = []string{"(write the prompt to " + p.file + ")", "(" + describe(found) + " " + out.Branch + ")", p.exec}
out.Note = p.note
return out, nil
}
if err := writePrompt(o, promptFile, prompt); err != nil {
if err := launch(o, b, wt, found, out, p); err != nil {
return out, err
}
st, err := start(o, b, wt, found, out.Branch, issue)
out.Started, out.Note = true, p.note
return out, nil
}

// launch leaves the prompt, starts the branch's worktree, leases it, and only
// then types the command: the lease must exist before anything runs in it.
func launch(o Options, b Backend, wt Worktree, found bool, out Outcome, p plan) error {
if err := writePrompt(o, p.file, p.prompt); err != nil {
return err
}
st, err := start(o, b, wt, found, out.Branch, out.Issue)
if err != nil {
return out, err
return err
}
if err := ensureLease(o, st.Worktree.Path, issue.Number, out.Agent); err != nil {
return out, err
if err := ensureLease(o, st.Worktree.Path, out.Issue.Number, out.Agent); err != nil {
return err
}
return b.Type(st.Pane, p.exec)
}
Comment on lines +106 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dry-run Side Effect

REAL: On the herdr path with found && wt.Open and o.DryRun false, ensureLease runs. Unchanged by this PR, but the new launch helper writes the prompt before start. If start fails, the prompt file is left behind under PromptDir with no lease, and a later run overwrites it. Low impact: stale prompt file only.


// plan is what a hand-off hands the successor: its prompt, the file the prompt
// is left in, the command line typed into its pane, and a note when that line
// is not quite what the caller asked for.
type plan struct{ prompt, file, exec, note string }

// prepare renders the prompt, settles the agent and builds the command line.
// out.Agent is set as a side effect, so a refusal still names it.
func prepare(o Options, b Backend, out *Outcome) (plan, error) {
prompt, err := RenderPrompt(o.Template, PromptData{
Number: out.Issue.Number, Title: out.Issue.Title, Branch: out.Branch, Remaining: remaining(o.Config.Queue, out.Issue.Number),
})
if err != nil {
return plan{}, err
}
if out.Agent, err = resolveAgent(o.Agent, b); err != nil {
return plan{}, err
}
if out.Agent != agentPlaceholder() {
if _, err := LaunchArgv(out.Agent, prompt); err != nil {
return plan{}, err
}
}
file := filepath.Join(o.PromptDir, strconv.Itoa(out.Issue.Number)+".md")
if !plainWord.MatchString(file) {
return plan{}, fmt.Errorf("lane: the prompt path %q has characters a pane's shell would read differently; use a PromptDir of letters, digits and _ . / : \\ ~ -", file)
}
dotf, note := o.Dotf, ""
if dotf == "" || !plainWord.MatchString(dotf) {
if dotf != "" {
note = fmt.Sprintf("the successor runs `dotf` from its pane's PATH: %q is not a path every shell reads alike", dotf)
}
dotf = "dotf"
}
if err := b.Type(st.Pane, exec); err != nil {
return plan{prompt, file, fmt.Sprintf("%s lane exec --agent %s --prompt-file %s", dotf, out.Agent, file), note}, nil
}

// noBackend answers a hand-off with nothing to start the successor in: the
// commands a person runs, and the prompt they read. When no agent was named it
// cannot be guessed (no pane to ask, and no harness is privileged), so the
// printed line carries a placeholder for the person to fill.
func noBackend(o Options, out Outcome, p plan) (Outcome, error) {
cmds, err := manualCommands(o, out, p.exec)
if err != nil {
return out, err
}
out.Started = true
return out, nil
out.Commands = cmds
notes := []string{"no session backend found; run these commands"}
if out.Agent == agentPlaceholder() {
notes = append(notes, "replace "+out.Agent+" with the agent to start")
}
if p.note != "" {
notes = append(notes, p.note)
}
out.Note = strings.Join(notes, "; ")
return out, writePrompt(o, p.file, p.prompt)
}

// start opens the worktree that exists, or creates the branch's off the base,
Expand Down Expand Up @@ -149,14 +200,19 @@ func describe(found bool) string {
return "create a worktree for"
}

// resolveAgent is the explicit agent, else the calling pane's. LaunchArgv then
// refuses one the table lacks: a pane running something else is a reason to
// say so, not to start a different kind of successor.
// agentPlaceholder stands in for the agent in the commands printed when there
// is no backend to ask and no --agent: a default would privilege one harness.
func agentPlaceholder() string { return "<" + strings.Join(Agents(), "|") + ">" }

// resolveAgent is the explicit agent, else the calling pane's, else (with no
// backend to ask) the placeholder. LaunchArgv then refuses one the table lacks:
// a pane running something else is a reason to say so, not to start a
// different kind of successor.
func resolveAgent(want string, b Backend) (string, error) {
agent := want
if agent == "" {
if b == nil {
return "", fmt.Errorf("lane: no --agent given and no session backend to ask (known: %s)", strings.Join(Agents(), ", "))
return agentPlaceholder(), nil
}
got, err := b.CallingAgent()
if err != nil {
Expand Down Expand Up @@ -185,13 +241,19 @@ func writePrompt(o Options, path, prompt string) error {
return os.WriteFile(path, []byte(prompt), 0o600)
}

func manualCommands(o Options, out Outcome, exec string) []string {
dir := filepath.Join(filepath.Dir(o.Parent), "worktrees", filepath.Base(o.Parent), Slug(out.Issue.Title))
base := o.Config.Base
// manualCommands is what a person runs to do what a backend would. The worktree
// goes where `dotf worktree add` and herdr put it, so `dotf worktree done` and
// the sweep find it by its branch.
func manualCommands(o Options, out Outcome, exec string) ([]string, error) {
if o.WorktreeRoot == "" {
return nil, errors.New("lane: WORKTREE_ROOT does not resolve, so the worktree's path cannot be printed; run `dotf doctor`")
}
dir := worktree.ResolveWorktreePath(o.WorktreeRoot, filepath.Base(o.Parent), out.Branch)
remote, _ := o.Config.Remote()
return []string{
"git -C " + o.Parent + " fetch " + strings.SplitN(base, "/", 2)[0],
"git -C " + o.Parent + " worktree add -b " + out.Branch + " " + dir + " " + base,
"git -C " + o.Parent + " fetch " + remote,
"git -C " + o.Parent + " worktree add -b " + out.Branch + " " + dir + " " + o.Config.Base,
"cd " + dir,
Comment on lines +254 to 256

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shell Quoting

THEORETICAL: manualCommands joins o.Parent, dir and out.Branch into a shell line without quoting. A repo path containing spaces (for example /home/a b/repo) yields a printed command a person cannot paste as-is. plainWord guards only the prompt and dotf paths, not these. Only affects the printed no-backend commands.

exec,
}
}, nil
}
Loading
Loading