From f724c6dc6e211e3eae557ab168c9486c08716e0c Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 25 Aug 2026 18:04:30 -0500 Subject: [PATCH 1/2] feat(onboarding): start a project from a GitHub repo URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-run onboarding could only point at a folder that already existed on disk. Now the folder picker's text input doubles as a repo field: paste a GitHub URL, press enter, and TaskYou clones it and continues down the existing folder-picked path. - internal/github/clone.go: RepoRef parsing for the forms people paste (https with/without .git or a trailing slash, git@host:owner/repo, owner/repo shorthand, host/owner/repo), plus destination resolution (reuse an existing clone of the same repo, step around an unrelated directory) and a clone that cleans up after itself on failure. Nothing is shelled out to git before parsing succeeds. - internal/ui/repoclone.go: the clone view — shows the destination before cloning, spinner while git runs, git's own stderr on failure with the destination still editable, esc to cancel. - internal/ui/folderpicker.go: recognises a pasted URL as a repo rather than a filter term, with an inline error for a URL that doesn't parse. - cmd/task: `ty projects create --repo `, mutually exclusive with --path, same parsing and clone. Once the clone lands the path goes to handleFolderPicked, so project creation isn't forked — from there it's an ordinary local-folder project. Co-Authored-By: Claude Opus 5 --- cmd/task/main.go | 132 +++++++--- cmd/task/projects_create_test.go | 57 +++++ internal/github/clone.go | 399 +++++++++++++++++++++++++++++ internal/github/clone_test.go | 357 ++++++++++++++++++++++++++ internal/ui/app.go | 39 +++ internal/ui/folderpicker.go | 65 ++++- internal/ui/folderpicker_test.go | 80 ++++++ internal/ui/repoclone.go | 313 ++++++++++++++++++++++ internal/ui/repoclone_flow_test.go | 94 +++++++ internal/ui/repoclone_test.go | 234 +++++++++++++++++ internal/ui/welcome.go | 2 +- 11 files changed, 1733 insertions(+), 39 deletions(-) create mode 100644 cmd/task/projects_create_test.go create mode 100644 internal/github/clone.go create mode 100644 internal/github/clone_test.go create mode 100644 internal/ui/repoclone.go create mode 100644 internal/ui/repoclone_flow_test.go create mode 100644 internal/ui/repoclone_test.go diff --git a/cmd/task/main.go b/cmd/task/main.go index 3f853ddb..b8d0bf34 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -3066,39 +3066,7 @@ Examples: projectsCmd.AddCommand(projectsShowCmd) // Projects create subcommand - projectsCreateCmd := &cobra.Command{ - Use: "create ", - Short: "Create a new project", - Long: `Create a new project with the specified name. - -Examples: - ty projects create myapp --path ~/Projects/myapp - ty projects create myapp --path ~/Projects/myapp --instructions "Use TypeScript" - ty projects create myapp --path ~/Projects/myapp --color "#61AFEF"`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - path, _ := cmd.Flags().GetString("path") - instructions, _ := cmd.Flags().GetString("instructions") - color, _ := cmd.Flags().GetString("color") - aliases, _ := cmd.Flags().GetString("aliases") - claudeConfigDir, _ := cmd.Flags().GetString("claude-config-dir") - permissionMode, _ := cmd.Flags().GetString("permission-mode") - noGit, _ := cmd.Flags().GetBool("no-git") - outputJSON, _ := cmd.Flags().GetBool("json") - - createProjectCLI(args[0], path, instructions, color, aliases, claudeConfigDir, permissionMode, noGit, outputJSON) - }, - } - projectsCreateCmd.Flags().StringP("path", "p", "", "Project directory path (required)") - projectsCreateCmd.Flags().StringP("instructions", "i", "", "Project-specific AI instructions") - projectsCreateCmd.Flags().StringP("color", "c", "", "Hex color for display (e.g., #61AFEF)") - projectsCreateCmd.Flags().StringP("aliases", "a", "", "Comma-separated aliases for lookup") - projectsCreateCmd.Flags().String("claude-config-dir", "", "Override CLAUDE_CONFIG_DIR for this project") - projectsCreateCmd.Flags().String("permission-mode", "", "Default permission mode for tasks: default (prompt), accept-edits (auto-accept file edits), auto (Claude Code auto mode), dangerous (skip all)") - projectsCreateCmd.Flags().Bool("no-git", false, "Disable git worktrees (for non-git projects)") - projectsCreateCmd.Flags().Bool("json", false, "Output in JSON format") - projectsCreateCmd.MarkFlagRequired("path") - projectsCmd.AddCommand(projectsCreateCmd) + projectsCmd.AddCommand(newProjectsCreateCmd()) // Projects update subcommand projectsUpdateCmd := &cobra.Command{ @@ -6596,17 +6564,111 @@ func showProjectCLI(name string, outputJSON bool) { } } +// newProjectsCreateCmd builds `ty projects create`. It lives in its own +// function so the flag rules (--path xor --repo) are testable. +func newProjectsCreateCmd() *cobra.Command { + projectsCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Create a new project", + Long: `Create a new project with the specified name. + +Point the project at a folder you already have (--path), or at a GitHub repo +to clone first (--repo). The two are mutually exclusive; a cloned repo lands in +~/Projects/ and becomes an ordinary path-based project. + +Examples: + ty projects create myapp --path ~/Projects/myapp + ty projects create myapp --repo https://github.com/owner/myapp + ty projects create myapp --repo owner/myapp + ty projects create myapp --path ~/Projects/myapp --instructions "Use TypeScript" + ty projects create myapp --path ~/Projects/myapp --color "#61AFEF"`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path, _ := cmd.Flags().GetString("path") + repo, _ := cmd.Flags().GetString("repo") + instructions, _ := cmd.Flags().GetString("instructions") + color, _ := cmd.Flags().GetString("color") + aliases, _ := cmd.Flags().GetString("aliases") + claudeConfigDir, _ := cmd.Flags().GetString("claude-config-dir") + permissionMode, _ := cmd.Flags().GetString("permission-mode") + noGit, _ := cmd.Flags().GetBool("no-git") + outputJSON, _ := cmd.Flags().GetBool("json") + + createProjectCLI(args[0], path, repo, instructions, color, aliases, claudeConfigDir, permissionMode, noGit, outputJSON) + }, + } + projectsCreateCmd.Flags().StringP("path", "p", "", "Project directory path") + projectsCreateCmd.Flags().StringP("repo", "r", "", "GitHub repo to clone (URL or owner/repo) — mutually exclusive with --path") + projectsCreateCmd.Flags().StringP("instructions", "i", "", "Project-specific AI instructions") + projectsCreateCmd.Flags().StringP("color", "c", "", "Hex color for display (e.g., #61AFEF)") + projectsCreateCmd.Flags().StringP("aliases", "a", "", "Comma-separated aliases for lookup") + projectsCreateCmd.Flags().String("claude-config-dir", "", "Override CLAUDE_CONFIG_DIR for this project") + projectsCreateCmd.Flags().String("permission-mode", "", "Default permission mode for tasks: default (prompt), accept-edits (auto-accept file edits), auto (Claude Code auto mode), dangerous (skip all)") + projectsCreateCmd.Flags().Bool("no-git", false, "Disable git worktrees (for non-git projects)") + projectsCreateCmd.Flags().Bool("json", false, "Output in JSON format") + projectsCreateCmd.MarkFlagsMutuallyExclusive("path", "repo") + projectsCreateCmd.MarkFlagsOneRequired("path", "repo") + return projectsCreateCmd +} + +// cloneRepoForCLI turns a repo URL into a local checkout and returns its path. +// It is the CLI half of the TUI's clone view: same parsing, same destination +// rules, same cleanup on failure. +func cloneRepoForCLI(repo string) string { + // Progress goes to stderr so --json keeps a clean stdout. + progress := os.Stderr + + ref, err := github.ParseRepoRef(repo) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + + cloner := github.Cloner{} + dest, err := cloner.Resolve(ref) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + if dest.Reuse { + fmt.Fprintln(progress, dimStyle.Render(fmt.Sprintf("Using existing clone of %s at %s", ref.Slug(), dest.Path))) + return dest.Path + } + if dest.Renamed { + fmt.Fprintln(progress, dimStyle.Render(fmt.Sprintf("%s is taken by something else — cloning to %s", + filepath.Join(filepath.Dir(dest.Path), ref.Name), dest.Path))) + } + + fmt.Fprintln(progress, dimStyle.Render(fmt.Sprintf("Cloning %s into %s...", ref.Slug(), dest.Path))) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := cloner.Clone(ctx, ref, dest.Path); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: clone failed")) + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + return dest.Path +} + // createProjectCLI creates a new project. -func createProjectCLI(name, path, instructions, color, aliases, claudeConfigDir, permissionMode string, noGit bool, outputJSON bool) { +func createProjectCLI(name, path, repo, instructions, color, aliases, claudeConfigDir, permissionMode string, noGit bool, outputJSON bool) { // Validate name if strings.TrimSpace(name) == "" { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: project name cannot be empty")) os.Exit(1) } + if repo != "" && path != "" { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: use --path or --repo, not both")) + os.Exit(1) + } + if repo != "" { + path = cloneRepoForCLI(repo) + } + // Expand path if path == "" { - fmt.Fprintln(os.Stderr, errorStyle.Render("Error: --path is required")) + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: --path or --repo is required")) os.Exit(1) } expandedPath := path diff --git a/cmd/task/projects_create_test.go b/cmd/task/projects_create_test.go new file mode 100644 index 00000000..50f32fa7 --- /dev/null +++ b/cmd/task/projects_create_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "io" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// runCreateFlags parses args through `ty projects create` without running it, +// so only the flag rules are exercised. +func runCreateFlags(t *testing.T, args ...string) error { + t.Helper() + cmd := newProjectsCreateCmd() + ran := false + cmd.Run = func(*cobra.Command, []string) { ran = true } + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + if err != nil && ran { + t.Fatal("flag validation should reject before the command body runs") + } + return err +} + +func TestProjectsCreate_RepoAndPathAreMutuallyExclusive(t *testing.T) { + err := runCreateFlags(t, "myapp", "--path", "/tmp/myapp", "--repo", "owner/myapp") + if err == nil { + t.Fatal("--path together with --repo should be a usage error") + } + if !strings.Contains(err.Error(), "path") || !strings.Contains(err.Error(), "repo") { + t.Errorf("the error should name both flags, got %q", err) + } +} + +func TestProjectsCreate_OneSourceIsRequired(t *testing.T) { + err := runCreateFlags(t, "myapp") + if err == nil { + t.Fatal("creating a project needs either --path or --repo") + } + if !strings.Contains(err.Error(), "path") || !strings.Contains(err.Error(), "repo") { + t.Errorf("the error should name both flags, got %q", err) + } +} + +func TestProjectsCreate_EitherSourceAloneIsAccepted(t *testing.T) { + for _, args := range [][]string{ + {"myapp", "--path", "/tmp/myapp"}, + {"myapp", "--repo", "https://github.com/owner/myapp"}, + } { + if err := runCreateFlags(t, args...); err != nil { + t.Errorf("%v should be accepted, got %v", args, err) + } + } +} diff --git a/internal/github/clone.go b/internal/github/clone.go new file mode 100644 index 00000000..de497e87 --- /dev/null +++ b/internal/github/clone.go @@ -0,0 +1,399 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// DefaultHost is the host assumed for shorthand refs ("owner/repo"). +const DefaultHost = "github.com" + +// RepoRef identifies a repository on a git host. It is the normalized form of +// every URL shape a user might paste (https, scp-style ssh, or shorthand). +type RepoRef struct { + Host string // e.g. "github.com" + Owner string + Name string + // SSH records that the user pasted an ssh URL. We clone back over ssh so + // their key-based auth keeps working for private repos. + SSH bool +} + +// Slug is the "owner/repo" form used in prose and UI copy. +func (r RepoRef) Slug() string { return r.Owner + "/" + r.Name } + +// String is the slug, qualified with the host when it isn't github.com. +func (r RepoRef) String() string { + if !strings.EqualFold(r.Host, DefaultHost) { + return r.Host + "/" + r.Slug() + } + return r.Slug() +} + +// CloneURL is the URL handed to `git clone`. +func (r RepoRef) CloneURL() string { + if r.SSH { + return fmt.Sprintf("git@%s:%s/%s.git", r.Host, r.Owner, r.Name) + } + return fmt.Sprintf("https://%s/%s/%s.git", r.Host, r.Owner, r.Name) +} + +// SameRepo reports whether two refs point at the same repository. Host, owner +// and name are compared case-insensitively; the transport is ignored, so an +// https clone matches an ssh remote. +func (r RepoRef) SameRepo(other RepoRef) bool { + return strings.EqualFold(r.Host, other.Host) && + strings.EqualFold(r.Owner, other.Owner) && + strings.EqualFold(r.Name, other.Name) +} + +var ( + // GitHub owners are alphanumeric plus hyphens; other hosts are looser, so + // dots and underscores are tolerated. Anything else is rejected rather + // than handed to git. + ownerPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + hostPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}(:[0-9]+)?$`) + scpPattern = regexp.MustCompile(`^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+):(.+)$`) +) + +// ParseRepoRef normalizes the repo URL forms people actually paste: +// +// https://github.com/owner/repo (with or without .git or a trailing /) +// git@github.com:owner/repo.git +// ssh://git@github.com/owner/repo.git +// github.com/owner/repo +// owner/repo +// +// Anything else returns an error whose text names the next step, so it can be +// shown inline. Nothing is shelled out to git before this succeeds. +func ParseRepoRef(input string) (RepoRef, error) { + s := strings.TrimSpace(input) + s = strings.Trim(s, "<>\"'") + if s == "" { + return RepoRef{}, errors.New("enter a repo URL, e.g. https://github.com/owner/repo") + } + + host := DefaultHost + ssh := false + var path string + + switch { + case strings.Contains(s, "://"): + u, err := url.Parse(s) + if err != nil { + return RepoRef{}, fmt.Errorf("that doesn't look like a repo URL — try https://github.com/owner/repo") + } + switch strings.ToLower(u.Scheme) { + case "https", "http": + case "ssh", "git": + ssh = true + default: + return RepoRef{}, fmt.Errorf("%s:// URLs aren't supported — try https://github.com/owner/repo", u.Scheme) + } + host = u.Host + path = u.Path + case scpPattern.MatchString(s) && strings.Contains(s, "@"): + m := scpPattern.FindStringSubmatch(s) + host, path, ssh = m[1], m[2], true + default: + // Shorthand: "owner/repo", or "github.com/owner/repo". + segments := strings.Split(strings.Trim(s, "/"), "/") + if len(segments) > 2 && hostPattern.MatchString(segments[0]) { + host = segments[0] + path = strings.Join(segments[1:], "/") + } else { + path = s + } + } + + if !hostPattern.MatchString(host) { + return RepoRef{}, fmt.Errorf("%q isn't a host TaskYou can clone from — try https://github.com/owner/repo", host) + } + + segments := strings.Split(strings.Trim(path, "/"), "/") + if len(segments) > 2 { + // A link to a file, branch or issue inside a repo. Name the repo part + // so the fix is one edit away. + return RepoRef{}, fmt.Errorf("that URL points inside a repo — use just the repo: %s/%s", + segments[0], strings.TrimSuffix(segments[1], ".git")) + } + if len(segments) != 2 { + return RepoRef{}, errors.New("a repo needs an owner and a name, e.g. https://github.com/owner/repo") + } + + owner := segments[0] + name := strings.TrimSuffix(segments[1], ".git") + if !ownerPattern.MatchString(owner) || !namePattern.MatchString(name) { + return RepoRef{}, errors.New("that doesn't look like a repo URL — try https://github.com/owner/repo") + } + + return RepoRef{Host: strings.ToLower(host), Owner: owner, Name: name, SSH: ssh}, nil +} + +// LooksLikeRepoRef reports whether input is shaped like a repo URL at all — +// used to decide whether a parse failure is worth an inline error (a paste +// gone wrong) or just an ordinary search term. +func LooksLikeRepoRef(input string) bool { + s := strings.TrimSpace(input) + if s == "" { + return false + } + return strings.Contains(s, "://") || + strings.Contains(s, "@") || + strings.Contains(s, "github.com") || + strings.Count(strings.Trim(s, "/"), "/") >= 1 +} + +// CloneDestination is where a clone will land, and how we got there. +type CloneDestination struct { + Path string + // Reuse is true when Path already holds a checkout of the same repo, so + // there's nothing to clone — the caller can adopt it as-is. + Reuse bool + // Renamed is true when the natural directory name was taken by something + // else and a non-colliding one was picked instead. + Renamed bool +} + +// Cloner clones repos into a root directory. The zero value clones into +// ~/Projects using the git binary; tests substitute the two seams. +type Cloner struct { + // Root is where clones land. Empty means ~/Projects. + Root string + // RemoteURL returns dir's origin remote. Nil means ask git. + RemoteURL func(dir string) (string, error) + // Run performs the clone itself. Nil means run `git clone`. + Run func(ctx context.Context, cloneURL, dest string) error +} + +// DefaultCloneRoot is where clones land unless told otherwise: ~/Projects. +func DefaultCloneRoot() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "Projects" + } + return filepath.Join(home, "Projects") +} + +func (c Cloner) root() string { + if c.Root != "" { + return c.Root + } + return DefaultCloneRoot() +} + +func (c Cloner) remoteURL(dir string) (string, error) { + if c.RemoteURL != nil { + return c.RemoteURL(dir) + } + return gitOriginURL(dir) +} + +// maxDestinationAttempts bounds the "repo-2, repo-3, …" search so a wedged +// directory tree can't spin forever. +const maxDestinationAttempts = 50 + +// Resolve picks the directory ref should be cloned into. An existing checkout +// of the same repo is reused; an unrelated directory of the same name is +// stepped around ("repo-2"); an existing empty directory is used as-is, +// because git clones into those happily. +func (c Cloner) Resolve(ref RepoRef) (CloneDestination, error) { + base := filepath.Join(c.root(), ref.Name) + for i := 1; i <= maxDestinationAttempts; i++ { + candidate := base + if i > 1 { + candidate = base + "-" + strconv.Itoa(i) + } + switch { + case !pathExists(candidate) || dirIsEmpty(candidate): + return CloneDestination{Path: candidate, Renamed: i > 1}, nil + case c.IsCheckoutOf(candidate, ref): + return CloneDestination{Path: candidate, Reuse: true, Renamed: i > 1}, nil + } + } + return CloneDestination{}, fmt.Errorf("no free directory near %s — clone somewhere else", base) +} + +// IsCheckoutOf reports whether dir is a git checkout whose origin is ref. +func (c Cloner) IsCheckoutOf(dir string, ref RepoRef) bool { + if !pathExists(filepath.Join(dir, ".git")) { + return false + } + remote, err := c.remoteURL(dir) + if err != nil || strings.TrimSpace(remote) == "" { + return false + } + parsed, err := ParseRepoRef(remote) + if err != nil { + return false + } + return parsed.SameRepo(ref) +} + +// CloneError carries git's own stderr so the UI can show the user exactly what +// git said (bad auth, no such repo, no network). +type CloneError struct { + Stderr string + Err error +} + +func (e *CloneError) Error() string { + if msg := CloneErrorMessage(e.Stderr); msg != "" { + return msg + } + if e.Err != nil { + return e.Err.Error() + } + return "clone failed" +} + +func (e *CloneError) Unwrap() error { return e.Err } + +// CloneErrorMessage distills git's stderr to the lines worth showing: progress +// chatter and the "Cloning into…" banner are dropped, the rest is kept verbatim. +func CloneErrorMessage(stderr string) string { + noise := []string{ + "Cloning into", + "Receiving objects", + "Resolving deltas", + "Updating files", + "remote: Enumerating", + "remote: Counting", + "remote: Compressing", + "remote: Total", + } + var kept []string + for _, line := range strings.Split(stderr, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + skip := false + for _, prefix := range noise { + if strings.HasPrefix(line, prefix) { + skip = true + break + } + } + if !skip { + kept = append(kept, line) + } + } + if len(kept) == 0 { + return "" + } + // The last few lines carry the actual reason; earlier ones are context. + if len(kept) > 3 { + kept = kept[len(kept)-3:] + } + if hint := authHint(stderr); hint != "" { + kept = append(kept, hint) + } + return strings.Join(kept, "\n") +} + +// authHint spells out the next step when git couldn't get at the repo. Clones +// run with prompts disabled (a git waiting on a password looks like a hung +// TUI), so "could not read Username" means "no credentials here", and a +// missing repo is as often private as it is mistyped. +func authHint(stderr string) string { + s := strings.ToLower(stderr) + for _, marker := range []string{ + "terminal prompts disabled", + "could not read username", + "authentication failed", + "repository not found", + "permission denied", + } { + if strings.Contains(s, marker) { + return "If the repo is private, sign in first (gh auth login), or paste the git@github.com: URL to use your ssh key." + } + } + return "" +} + +// Clone clones ref into dest. A failed or cancelled clone leaves nothing +// behind: whatever git managed to write is removed, and a directory that +// existed (empty) beforehand is restored empty. +func (c Cloner) Clone(ctx context.Context, ref RepoRef, dest string) error { + if dest == "" { + return errors.New("no destination to clone into") + } + preExisted := pathExists(dest) + if preExisted && !dirIsEmpty(dest) { + return fmt.Errorf("%s already exists and isn't empty — pick another destination", dest) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("could not create %s: %w", filepath.Dir(dest), err) + } + + run := c.Run + if run == nil { + run = gitClone + } + if err := run(ctx, ref.CloneURL(), dest); err != nil { + cleanupPartialClone(dest, preExisted) + return err + } + return nil +} + +// cleanupPartialClone removes a half-written clone. If the destination existed +// (and was empty) before we started, it is left behind empty as we found it. +func cleanupPartialClone(dest string, preExisted bool) { + if err := os.RemoveAll(dest); err != nil { + return + } + if preExisted { + _ = os.MkdirAll(dest, 0o755) + } +} + +// gitClone is the real clone: `git clone `, with stderr captured. +func gitClone(ctx context.Context, cloneURL, dest string) error { + if _, err := exec.LookPath("git"); err != nil { + return errors.New("git not found — install git, then try again") + } + var stderr strings.Builder + cmd := exec.CommandContext(ctx, "git", "clone", cloneURL, dest) + cmd.Stderr = &stderr + // Never let git stop for a credentials prompt: there's no terminal to + // answer it, and a hung clone looks like a hung TUI. + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=") + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &CloneError{Stderr: stderr.String(), Err: err} + } + return nil +} + +// gitOriginURL reads dir's origin remote. +func gitOriginURL(dir string) (string, error) { + out, err := exec.Command("git", "-C", dir, "remote", "get-url", "origin").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func pathExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// dirIsEmpty reports whether p is a directory with no entries at all. +func dirIsEmpty(p string) bool { + entries, err := os.ReadDir(p) + return err == nil && len(entries) == 0 +} diff --git a/internal/github/clone_test.go b/internal/github/clone_test.go new file mode 100644 index 00000000..c5b979cf --- /dev/null +++ b/internal/github/clone_test.go @@ -0,0 +1,357 @@ +package github + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseRepoRef_AcceptedForms(t *testing.T) { + want := RepoRef{Host: "github.com", Owner: "bborn", Name: "taskyou"} + cases := []struct { + in string + ssh bool + }{ + {in: "https://github.com/bborn/taskyou"}, + {in: "https://github.com/bborn/taskyou/"}, + {in: "https://github.com/bborn/taskyou.git"}, + {in: "https://github.com/bborn/taskyou.git/"}, + {in: "http://github.com/bborn/taskyou"}, + {in: " https://github.com/bborn/taskyou "}, + {in: "git@github.com:bborn/taskyou.git", ssh: true}, + {in: "git@github.com:bborn/taskyou", ssh: true}, + {in: "ssh://git@github.com/bborn/taskyou.git", ssh: true}, + {in: "github.com/bborn/taskyou"}, + {in: "bborn/taskyou"}, + {in: "bborn/taskyou.git"}, + } + for _, tc := range cases { + got, err := ParseRepoRef(tc.in) + if err != nil { + t.Errorf("ParseRepoRef(%q) returned error: %v", tc.in, err) + continue + } + if !got.SameRepo(want) { + t.Errorf("ParseRepoRef(%q) = %+v, want same repo as %+v", tc.in, got, want) + } + if got.SSH != tc.ssh { + t.Errorf("ParseRepoRef(%q).SSH = %v, want %v", tc.in, got.SSH, tc.ssh) + } + } +} + +func TestParseRepoRef_Rejects(t *testing.T) { + cases := []string{ + "", + " ", + "taskyou", + "not a url", + "https://github.com/bborn", + "https://github.com/", + "ftp://github.com/bborn/taskyou", + "file:///etc/passwd", + "bborn/taskyou; rm -rf /", + "https://github.com/bborn/task you", + "/bborn/", + "-bborn/taskyou", + "https://github.com/bborn/../../etc", + } + for _, in := range cases { + if got, err := ParseRepoRef(in); err == nil { + t.Errorf("ParseRepoRef(%q) = %+v, want error", in, got) + } + } +} + +func TestParseRepoRef_DeepLinkNamesTheRepo(t *testing.T) { + _, err := ParseRepoRef("https://github.com/bborn/taskyou/tree/main/internal") + if err == nil { + t.Fatal("expected an error for a URL pointing inside a repo") + } + if !strings.Contains(err.Error(), "bborn/taskyou") { + t.Errorf("error should name the repo to use, got %q", err) + } +} + +func TestRepoRef_CloneURL(t *testing.T) { + https, _ := ParseRepoRef("https://github.com/bborn/taskyou") + if got, want := https.CloneURL(), "https://github.com/bborn/taskyou.git"; got != want { + t.Errorf("CloneURL() = %q, want %q", got, want) + } + ssh, _ := ParseRepoRef("git@github.com:bborn/taskyou.git") + if got, want := ssh.CloneURL(), "git@github.com:bborn/taskyou.git"; got != want { + t.Errorf("ssh CloneURL() = %q, want %q", got, want) + } +} + +func TestRepoRef_SameRepoIgnoresCaseAndTransport(t *testing.T) { + a, _ := ParseRepoRef("https://github.com/BBorn/TaskYou.git") + b, _ := ParseRepoRef("git@github.com:bborn/taskyou") + if !a.SameRepo(b) { + t.Errorf("%+v and %+v should be the same repo", a, b) + } + other, _ := ParseRepoRef("bborn/other") + if a.SameRepo(other) { + t.Errorf("%+v and %+v should not be the same repo", a, other) + } +} + +func TestRepoRef_StringQualifiesNonGitHubHosts(t *testing.T) { + gh, _ := ParseRepoRef("bborn/taskyou") + if got, want := gh.String(), "bborn/taskyou"; got != want { + t.Errorf("String() = %q, want %q", got, want) + } + gl, _ := ParseRepoRef("https://gitlab.com/bborn/taskyou") + if got, want := gl.String(), "gitlab.com/bborn/taskyou"; got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} + +// fakeCheckout makes dir look like a git checkout of remote. +func fakeCheckout(t *testing.T, dir, remote string, remotes map[string]string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + remotes[dir] = remote +} + +func testCloner(t *testing.T, remotes map[string]string) (Cloner, string) { + t.Helper() + root := t.TempDir() + return Cloner{ + Root: root, + RemoteURL: func(dir string) (string, error) { + if url, ok := remotes[dir]; ok { + return url, nil + } + return "", errors.New("no origin remote") + }, + }, root +} + +func TestResolve_FreshDestination(t *testing.T) { + c, root := testCloner(t, map[string]string{}) + ref, _ := ParseRepoRef("bborn/taskyou") + + dest, err := c.Resolve(ref) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if want := filepath.Join(root, "taskyou"); dest.Path != want { + t.Errorf("Path = %q, want %q", dest.Path, want) + } + if dest.Reuse || dest.Renamed { + t.Errorf("fresh destination should be neither reused nor renamed: %+v", dest) + } +} + +func TestResolve_ExistingCloneOfSameRepoIsReused(t *testing.T) { + remotes := map[string]string{} + c, root := testCloner(t, remotes) + ref, _ := ParseRepoRef("https://github.com/bborn/taskyou") + existing := filepath.Join(root, "taskyou") + // An ssh remote for the same repo still counts as the same checkout. + fakeCheckout(t, existing, "git@github.com:bborn/taskyou.git", remotes) + + dest, err := c.Resolve(ref) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if dest.Path != existing || !dest.Reuse { + t.Errorf("Resolve = %+v, want reuse of %q", dest, existing) + } +} + +func TestResolve_UnrelatedDirectoryGetsNonCollidingName(t *testing.T) { + remotes := map[string]string{} + c, root := testCloner(t, remotes) + ref, _ := ParseRepoRef("bborn/taskyou") + + // Something else entirely lives at ~/Projects/taskyou. + if err := os.MkdirAll(filepath.Join(root, "taskyou", "src"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + dest, err := c.Resolve(ref) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if want := filepath.Join(root, "taskyou-2"); dest.Path != want { + t.Errorf("Path = %q, want %q", dest.Path, want) + } + if !dest.Renamed || dest.Reuse { + t.Errorf("Resolve = %+v, want renamed and not reused", dest) + } +} + +func TestResolve_SkipsPastUnrelatedDirsToAnExistingClone(t *testing.T) { + remotes := map[string]string{} + c, root := testCloner(t, remotes) + ref, _ := ParseRepoRef("bborn/taskyou") + + if err := os.MkdirAll(filepath.Join(root, "taskyou", "src"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // A checkout of a *different* repo also occupies taskyou-2. + fakeCheckout(t, filepath.Join(root, "taskyou-2"), "https://github.com/someone/taskyou.git", remotes) + fakeCheckout(t, filepath.Join(root, "taskyou-3"), "https://github.com/bborn/taskyou.git", remotes) + + dest, err := c.Resolve(ref) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if want := filepath.Join(root, "taskyou-3"); dest.Path != want || !dest.Reuse { + t.Errorf("Resolve = %+v, want reuse of %q", dest, want) + } +} + +func TestResolve_ExistingEmptyDirectoryIsUsedAsIs(t *testing.T) { + c, root := testCloner(t, map[string]string{}) + ref, _ := ParseRepoRef("bborn/taskyou") + empty := filepath.Join(root, "taskyou") + if err := os.MkdirAll(empty, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + dest, err := c.Resolve(ref) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if dest.Path != empty || dest.Renamed || dest.Reuse { + t.Errorf("Resolve = %+v, want plain use of %q", dest, empty) + } +} + +func TestClone_UsesRefCloneURLAndDestination(t *testing.T) { + root := t.TempDir() + var gotURL, gotDest string + c := Cloner{Root: root, Run: func(_ context.Context, url, dest string) error { + gotURL, gotDest = url, dest + return os.MkdirAll(filepath.Join(dest, ".git"), 0o755) + }} + ref, _ := ParseRepoRef("bborn/taskyou") + dest := filepath.Join(root, "taskyou") + + if err := c.Clone(context.Background(), ref, dest); err != nil { + t.Fatalf("Clone: %v", err) + } + if gotURL != "https://github.com/bborn/taskyou.git" { + t.Errorf("clone url = %q", gotURL) + } + if gotDest != dest { + t.Errorf("clone dest = %q, want %q", gotDest, dest) + } +} + +func TestClone_FailureLeavesNoPartialClone(t *testing.T) { + root := t.TempDir() + c := Cloner{Root: root, Run: func(_ context.Context, _, dest string) error { + // Half a clone on disk, then a failure — exactly what git leaves. + if err := os.MkdirAll(filepath.Join(dest, ".git", "objects"), 0o755); err != nil { + return err + } + return &CloneError{Stderr: "remote: Repository not found.\nfatal: repository not found", Err: errors.New("exit 128")} + }} + ref, _ := ParseRepoRef("bborn/nope") + dest := filepath.Join(root, "nope") + + err := c.Clone(context.Background(), ref, dest) + if err == nil { + t.Fatal("expected clone to fail") + } + if !strings.Contains(err.Error(), "Repository not found") { + t.Errorf("error should surface git's stderr, got %q", err) + } + if pathExists(dest) { + t.Errorf("%s should have been removed after a failed clone", dest) + } +} + +func TestClone_FailureRestoresAPreExistingEmptyDirectory(t *testing.T) { + root := t.TempDir() + dest := filepath.Join(root, "taskyou") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + c := Cloner{Root: root, Run: func(_ context.Context, _, d string) error { + if err := os.MkdirAll(filepath.Join(d, ".git"), 0o755); err != nil { + return err + } + return errors.New("boom") + }} + ref, _ := ParseRepoRef("bborn/taskyou") + + if err := c.Clone(context.Background(), ref, dest); err == nil { + t.Fatal("expected clone to fail") + } + if !dirIsEmpty(dest) { + t.Errorf("%s should be left empty, as it was found", dest) + } +} + +func TestClone_RefusesToWriteIntoANonEmptyDirectory(t *testing.T) { + root := t.TempDir() + dest := filepath.Join(root, "taskyou") + if err := os.MkdirAll(filepath.Join(dest, "src"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + ran := false + c := Cloner{Root: root, Run: func(_ context.Context, _, _ string) error { ran = true; return nil }} + ref, _ := ParseRepoRef("bborn/taskyou") + + if err := c.Clone(context.Background(), ref, dest); err == nil { + t.Fatal("expected an error for a non-empty destination") + } + if ran { + t.Error("clone should not have run against a non-empty directory") + } + if !pathExists(filepath.Join(dest, "src")) { + t.Error("existing contents must be left alone") + } +} + +func TestCloneErrorMessage_KeepsTheReason(t *testing.T) { + stderr := "Cloning into '/home/u/Projects/taskyou'...\n" + + "remote: Enumerating objects: 12, done.\n" + + "remote: Repository not found.\n" + + "fatal: repository 'https://github.com/bborn/nope.git/' not found\n" + got := CloneErrorMessage(stderr) + if strings.Contains(got, "Cloning into") || strings.Contains(got, "Enumerating") { + t.Errorf("progress chatter should be dropped, got %q", got) + } + if !strings.Contains(got, "Repository not found") || !strings.Contains(got, "fatal:") { + t.Errorf("the reason should survive, got %q", got) + } +} + +func TestLooksLikeRepoRef(t *testing.T) { + for _, in := range []string{"https://github.com/bborn/taskyou", "git@github.com:bborn/taskyou.git", "bborn/taskyou", "github.com/x"} { + if !LooksLikeRepoRef(in) { + t.Errorf("LooksLikeRepoRef(%q) = false, want true", in) + } + } + for _, in := range []string{"", "taskyou", "my project"} { + if LooksLikeRepoRef(in) { + t.Errorf("LooksLikeRepoRef(%q) = true, want false", in) + } + } +} + +func TestCloneErrorMessage_AddsTheNextStepForAuthFailures(t *testing.T) { + got := CloneErrorMessage("fatal: could not read Username for 'https://github.com': terminal prompts disabled") + if !strings.Contains(got, "could not read Username") { + t.Errorf("git's own words should survive, got %q", got) + } + if !strings.Contains(got, "gh auth login") { + t.Errorf("an auth failure should name the fix, got %q", got) + } + + plain := CloneErrorMessage("fatal: unable to access 'https://github.com/o/r.git/': Could not resolve host: github.com") + if strings.Contains(plain, "gh auth login") { + t.Errorf("a network failure isn't an auth problem, got %q", plain) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 2414313f..207a2711 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -54,6 +54,7 @@ const ( ViewFolderPicker // fuzzy folder picker for "set up a project" ViewRoutines // global routines fleet-health view ViewActionPicker // modal list of plugin actions for the current task + ViewRepoClone // clone a pasted repo URL, then continue as a folder ) // KeyMap defines key bindings. @@ -440,6 +441,7 @@ type AppModel struct { // First-run onboarding views welcomeView *WelcomeModel folderPicker *FolderPickerModel + repoClone *RepoCloneModel // Delete confirmation state deleteConfirm *huh.Form @@ -761,6 +763,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if picked, ok := msg.(folderPickedMsg); ok { return m.handleFolderPicked(picked.path) } + if req, ok := msg.(repoRequestedMsg); ok { + m.folderPicker = nil + m.repoClone = NewRepoCloneModel(req.ref, m.width, m.height) + m.currentView = ViewRepoClone + return m, m.repoClone.Init() + } if key, ok := msg.(tea.KeyMsg); ok && (key.String() == "esc" || key.String() == "ctrl+c") { m.folderPicker = nil m.welcomeView = NewWelcomeModel(m.width, m.height, m.availableExecutors, tmuxAvailable()) @@ -771,6 +779,29 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.folderPicker, cmd = m.folderPicker.Update(msg) return m, cmd } + // Repo clone: same deal as the folder picker — every message reaches it + // so the destination input and the clone spinner stay live. It hands + // back a local path (repoClonedMsg), which rejoins the folder-picked + // path so project creation isn't forked. + if m.currentView == ViewRepoClone && m.repoClone != nil { + if done, ok := msg.(repoClonedMsg); ok { + m.repoClone = nil + return m.handleFolderPicked(done.path) + } + if key, ok := msg.(tea.KeyMsg); ok && (key.String() == "esc" || key.String() == "ctrl+c") { + // esc stops a running clone first; a second esc goes back. + if m.repoClone.Cancel() { + return m, nil + } + m.repoClone = nil + m.folderPicker = NewFolderPickerModel(m.width, m.height) + m.currentView = ViewFolderPicker + return m, m.folderPicker.Init() + } + var cmd tea.Cmd + m.repoClone, cmd = m.repoClone.Update(msg) + return m, cmd + } if m.currentView == ViewDeleteConfirm && m.deleteConfirm != nil { return m.updateDeleteConfirm(msg) } @@ -1544,6 +1575,9 @@ func (m *AppModel) applyWindowSize(width, height int) { if m.folderPicker != nil { m.folderPicker.SetSize(m.width, m.height) } + if m.repoClone != nil { + m.repoClone.SetSize(m.width, m.height) + } } // View renders the current view. @@ -1590,6 +1624,10 @@ func (m *AppModel) View() string { if m.folderPicker != nil { return m.folderPicker.View() } + case ViewRepoClone: + if m.repoClone != nil { + return m.repoClone.View() + } case ViewDeleteConfirm: return m.viewDeleteConfirm() case ViewCloseConfirm: @@ -3146,6 +3184,7 @@ func (m *AppModel) onlyPersonalProject() bool { // confirm card used for auto-detected projects. Metadata inference runs // asynchronously (see showProjectDetectConfirm) so the card appears instantly. func (m *AppModel) handleFolderPicked(path string) (tea.Model, tea.Cmd) { + m.repoClone = nil if proj, err := m.db.GetProjectByPath(path); err == nil && proj != nil { m.folderPicker = nil m.notification = fmt.Sprintf("%s \"%s\" already covers that folder", IconDone(), proj.Name) diff --git a/internal/ui/folderpicker.go b/internal/ui/folderpicker.go index b8895d70..6acc7614 100644 --- a/internal/ui/folderpicker.go +++ b/internal/ui/folderpicker.go @@ -12,6 +12,8 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + + "github.com/bborn/workflow/internal/github" ) // folderEntry is one selectable folder in the picker. @@ -23,6 +25,10 @@ type folderEntry struct { // folderPickedMsg is emitted when the user picks a folder (enter). type folderPickedMsg struct{ path string } +// repoRequestedMsg is emitted when the text typed into the picker is a repo +// URL rather than a filter term, and the user pressed enter on it. +type repoRequestedMsg struct{ ref github.RepoRef } + // folderItem adapts a folderEntry to bubbles/list: the title is the folder // basename and the description is the ~-collapsed parent directory. type folderItem struct { @@ -59,12 +65,18 @@ type FolderPickerModel struct { height int home string root string // non-empty once the user has descended into a folder + + // repoRef is set while the typed text parses as a repo URL: enter clones + // it instead of picking a folder. repoErr holds the inline complaint when + // the text looks like a URL but isn't one we can clone. + repoRef *github.RepoRef + repoErr string } // NewFolderPickerModel seeds the picker from common project roots. func NewFolderPickerModel(width, height int) *FolderPickerModel { ti := textinput.New() - ti.Placeholder = "type to filter…" + ti.Placeholder = "type to filter, or paste a repo URL…" ti.Focus() ti.Prompt = Icon("❯ ", "> ") ti.PromptStyle = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) @@ -174,6 +186,12 @@ func (m *FolderPickerModel) Update(msg tea.Msg) (*FolderPickerModel, tea.Cmd) { } return m, nil case "enter": + // A pasted repo URL takes precedence: there is no folder to pick + // yet, we have to clone it first. + if m.repoRef != nil { + ref := *m.repoRef + return m, func() tea.Msg { return repoRequestedMsg{ref: ref} } + } if it, ok := m.list.SelectedItem().(folderItem); ok { picked := it.path return m, func() tea.Msg { return folderPickedMsg{path: picked} } @@ -185,6 +203,7 @@ func (m *FolderPickerModel) Update(msg tea.Msg) (*FolderPickerModel, tea.Cmd) { before := m.input.Value() m.input, cmd = m.input.Update(msg) if q := m.input.Value(); q != before { + m.classifyInput(q) if strings.TrimSpace(q) == "" { m.list.ResetFilter() m.list.ResetSelected() @@ -195,6 +214,35 @@ func (m *FolderPickerModel) Update(msg tea.Msg) (*FolderPickerModel, tea.Cmd) { return m, cmd } +// classifyInput decides whether what's been typed is a repo URL to clone or an +// ordinary filter term. Text that merely looks like a URL but doesn't parse +// gets an inline complaint rather than silently filtering to nothing. +func (m *FolderPickerModel) classifyInput(q string) { + m.repoRef, m.repoErr = nil, "" + q = strings.TrimSpace(q) + if q == "" || isExistingDir(q, m.home) { + return + } + ref, err := github.ParseRepoRef(q) + if err == nil { + m.repoRef = &ref + return + } + if github.LooksLikeRepoRef(q) { + m.repoErr = err.Error() + } +} + +// isExistingDir reports whether the typed text is already a directory on this +// machine — if it is, it's a path, not a repo to clone. +func isExistingDir(p, home string) bool { + if strings.HasPrefix(p, "~") && home != "" { + p = filepath.Join(home, strings.TrimPrefix(p, "~")) + } + info, err := os.Stat(p) + return err == nil && info.IsDir() +} + // descend repopulates the list with the candidate children of dir. If dir has // no sub-directories it is treated as a leaf and left as-is: the user can // press enter to pick it. @@ -217,21 +265,26 @@ func (m *FolderPickerModel) descend(dir string) { sortFolderEntries(children) m.root = dir m.input.SetValue("") + m.classifyInput("") m.setEntries(children) } func (m *FolderPickerModel) View() string { w := m.contentWidth() - subtitle := "Pick the folder where your code lives" + subtitle := "Pick a folder, or paste a GitHub repo URL" if m.root != "" { subtitle = "In " + collapseHomePath(m.root, m.home) + " — pick a folder" } + enterDesc := "pick" + if m.repoRef != nil { + enterDesc = "clone" + } help := HelpBar.Render( HelpKey.Render("↑↓") + " " + HelpDesc.Render("select") + " " + HelpKey.Render("→") + " " + HelpDesc.Render("open") + " " + - HelpKey.Render("enter") + " " + HelpDesc.Render("pick") + " " + + HelpKey.Render("enter") + " " + HelpDesc.Render(enterDesc) + " " + HelpKey.Render("esc") + " " + HelpDesc.Render("back")) content := lipgloss.JoinVertical(lipgloss.Left, @@ -257,6 +310,12 @@ func (m *FolderPickerModel) View() string { // countLine summarises what the list is showing, e.g. "18 folders · git repos // first", or "3 of 18 folders match" while a filter is active. func (m *FolderPickerModel) countLine() string { + if m.repoRef != nil { + return Success.Render(" " + Icon("⏺", "*") + " enter to clone " + m.repoRef.String()) + } + if m.repoErr != "" { + return Error.Render(" " + truncateRunes(m.repoErr, m.contentWidth()-2)) + } total := len(m.list.Items()) noun := "folders" if total == 1 { diff --git a/internal/ui/folderpicker_test.go b/internal/ui/folderpicker_test.go index 7c3fd366..083e9a09 100644 --- a/internal/ui/folderpicker_test.go +++ b/internal/ui/folderpicker_test.go @@ -3,7 +3,10 @@ package ui import ( "os" "path/filepath" + "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" ) func TestCollectCandidateFolders(t *testing.T) { @@ -56,3 +59,80 @@ func TestSortFolderEntries(t *testing.T) { } } } + +// typeInto feeds a string into the picker one key at a time, the way a paste +// arrives on a terminal. +func typeInto(m *FolderPickerModel, text string) { + for _, r := range text { + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } +} + +func TestFolderPicker_RepoURLBecomesACloneOffer(t *testing.T) { + m := NewFolderPickerModel(100, 40) + typeInto(m, "https://github.com/bborn/taskyou") + + if m.repoRef == nil { + t.Fatalf("typed repo URL should be recognised, got repoErr %q", m.repoErr) + } + if got := m.repoRef.Slug(); got != "bborn/taskyou" { + t.Errorf("repoRef = %q, want bborn/taskyou", got) + } + if view := m.View(); !strings.Contains(view, "bborn/taskyou") || !strings.Contains(view, "clone") { + t.Errorf("view should offer to clone the repo, got:\n%s", view) + } +} + +func TestFolderPicker_EnterOnRepoURLRequestsAClone(t *testing.T) { + m := NewFolderPickerModel(100, 40) + typeInto(m, "git@github.com:bborn/taskyou.git") + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter on a repo URL should emit a command") + } + msg, ok := cmd().(repoRequestedMsg) + if !ok { + t.Fatalf("want repoRequestedMsg, got %T", cmd()) + } + if msg.ref.Slug() != "bborn/taskyou" || !msg.ref.SSH { + t.Errorf("ref = %+v, want the ssh form of bborn/taskyou", msg.ref) + } +} + +func TestFolderPicker_PlainTextStaysAFilter(t *testing.T) { + m := NewFolderPickerModel(100, 40) + typeInto(m, "taskyou") + + if m.repoRef != nil { + t.Errorf("a plain filter term should not be read as a repo URL: %+v", m.repoRef) + } + if m.repoErr != "" { + t.Errorf("a plain filter term should not raise an error, got %q", m.repoErr) + } +} + +func TestFolderPicker_BrokenURLGetsAnInlineError(t *testing.T) { + m := NewFolderPickerModel(100, 40) + typeInto(m, "https://github.com/bborn") + + if m.repoRef != nil { + t.Fatalf("an incomplete URL should not be clonable: %+v", m.repoRef) + } + if m.repoErr == "" { + t.Fatal("an incomplete URL should get an inline complaint") + } + if !strings.Contains(m.View(), "owner") { + t.Errorf("the error should name the shape we expect, got:\n%s", m.View()) + } +} + +func TestFolderPicker_ExistingDirectoryIsNotARepoURL(t *testing.T) { + dir := t.TempDir() + m := NewFolderPickerModel(100, 40) + typeInto(m, dir) + + if m.repoRef != nil { + t.Errorf("an existing local path should not be treated as a repo: %+v", m.repoRef) + } +} diff --git a/internal/ui/repoclone.go b/internal/ui/repoclone.go new file mode 100644 index 00000000..b5733de6 --- /dev/null +++ b/internal/ui/repoclone.go @@ -0,0 +1,313 @@ +package ui + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/bborn/workflow/internal/github" +) + +// repoCloneState is where the clone view is in its (short) life. +type repoCloneState int + +const ( + repoCloneConfirm repoCloneState = iota // showing the destination, waiting for enter + repoCloneRunning // git clone in flight + repoCloneFailed // git said no; the user can correct and retry +) + +// repoCloneDoneMsg reports the outcome of a clone attempt. +type repoCloneDoneMsg struct { + path string + err error +} + +// repoClonedMsg says the repo is on disk at path. The app hands it to +// handleFolderPicked, so from here on it's an ordinary local-folder project. +type repoClonedMsg struct{ path string } + +// repoCloneTickMsg drives the spinner while the clone runs. +type repoCloneTickMsg struct{} + +// repoCloneReuseNotice is what we say when the destination already holds the +// repo: nothing is downloaded, we just adopt it. +const repoCloneReuseNotice = "You already have this repo here — enter uses it as-is, nothing is downloaded." + +// RepoCloneModel confirms where a pasted repo URL should land, clones it, and +// reports the local path. It never creates a project itself — that stays with +// the folder-picked path. +type RepoCloneModel struct { + ref github.RepoRef + cloner github.Cloner + dest textinput.Model // ~-collapsed destination, editable + + state repoCloneState + reuse bool // the destination is already a checkout of this repo + notice string // how the destination was chosen, when it's worth saying + errText string // git's own words, or ours + + home string + // reuseChecked memoizes "is this path already a checkout of ref?" — the + // answer costs a git subprocess and the destination is edited a keystroke + // at a time. + reuseChecked map[string]bool + frame int + // cancel stops an in-flight clone (esc). Nil unless one is running. + cancel context.CancelFunc + + width int + height int +} + +// NewRepoCloneModel prepares a clone of ref into the default clone root. +func NewRepoCloneModel(ref github.RepoRef, width, height int) *RepoCloneModel { + return newRepoCloneModel(ref, github.Cloner{}, width, height) +} + +// newRepoCloneModel is the seam the tests use to substitute a fake cloner. +func newRepoCloneModel(ref github.RepoRef, cloner github.Cloner, width, height int) *RepoCloneModel { + home, _ := os.UserHomeDir() + ti := textinput.New() + ti.Prompt = Icon("❯ ", "> ") + ti.PromptStyle = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) + ti.Focus() + + m := &RepoCloneModel{ref: ref, cloner: cloner, dest: ti, home: home, width: width, height: height, reuseChecked: map[string]bool{}} + m.resolveDestination() + m.layout() + return m +} + +// resolveDestination picks where the clone goes and explains the choice when +// it wasn't the obvious one. +func (m *RepoCloneModel) resolveDestination() { + dest, err := m.cloner.Resolve(m.ref) + if err != nil { + m.state = repoCloneFailed + m.errText = err.Error() + m.dest.SetValue(collapseHomePath(filepath.Join(github.DefaultCloneRoot(), m.ref.Name), m.home)) + return + } + m.reuse = dest.Reuse + m.dest.SetValue(collapseHomePath(dest.Path, m.home)) + m.dest.CursorEnd() + switch { + case dest.Reuse: + m.notice = repoCloneReuseNotice + case dest.Renamed: + taken := collapseHomePath(filepath.Join(filepath.Dir(dest.Path), m.ref.Name), m.home) + m.notice = taken + " is something else, so this goes to " + collapseHomePath(dest.Path, m.home) + ". Edit the path to change it." + } +} + +func (m *RepoCloneModel) Init() tea.Cmd { return textinput.Blink } + +func (m *RepoCloneModel) Update(msg tea.Msg) (*RepoCloneModel, tea.Cmd) { + switch msg := msg.(type) { + case repoCloneTickMsg: + if m.state != repoCloneRunning { + return m, nil + } + m.frame++ + return m, m.tick() + + case repoCloneDoneMsg: + if m.cancel != nil { + m.cancel() // release the context now that the clone is over + m.cancel = nil + } + if msg.err != nil { + m.state = repoCloneFailed + m.errText = cloneFailureText(msg.err) + return m, nil + } + path := msg.path + m.state = repoCloneConfirm + return m, func() tea.Msg { return repoClonedMsg{path: path} } + + case tea.KeyMsg: + if m.state == repoCloneRunning { + return m, nil // the spinner owns the screen; esc is handled by the app + } + if msg.String() == "enter" { + return m, m.start() + } + } + + if m.state == repoCloneRunning { + return m, nil + } + var cmd tea.Cmd + before := m.dest.Value() + m.dest, cmd = m.dest.Update(msg) + if m.dest.Value() != before { + // The destination moved, so anything we said about the old one — + // "you already have this" or a stale git error — no longer holds. + m.reuse = m.isCheckoutOf(expandPath(m.dest.Value())) + m.notice = "" + if m.reuse { + m.notice = repoCloneReuseNotice + } + if m.state == repoCloneFailed { + m.state = repoCloneConfirm + m.errText = "" + } + } + return m, cmd +} + +// start kicks off the clone (or adopts an existing checkout unchanged). +func (m *RepoCloneModel) start() tea.Cmd { + path := strings.TrimSpace(expandPath(m.dest.Value())) + if path == "" { + m.state = repoCloneFailed + m.errText = "Enter a folder to clone into, e.g. " + collapseHomePath(filepath.Join(github.DefaultCloneRoot(), m.ref.Name), m.home) + return nil + } + if m.isCheckoutOf(path) { + return func() tea.Msg { return repoClonedMsg{path: path} } + } + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.state = repoCloneRunning + m.errText = "" + m.frame = 0 + + ref, cloner := m.ref, m.cloner + clone := func() tea.Msg { + err := cloner.Clone(ctx, ref, path) + return repoCloneDoneMsg{path: path, err: err} + } + return tea.Batch(clone, m.tick()) +} + +// isCheckoutOf is cloner.IsCheckoutOf, memoized per path. +func (m *RepoCloneModel) isCheckoutOf(path string) bool { + if got, ok := m.reuseChecked[path]; ok { + return got + } + got := m.cloner.IsCheckoutOf(path, m.ref) + m.reuseChecked[path] = got + return got +} + +// Cancel stops an in-flight clone. It reports whether there was one — the app +// uses that to decide whether esc means "stop cloning" or "go back". +func (m *RepoCloneModel) Cancel() bool { + if m.state != repoCloneRunning || m.cancel == nil { + return false + } + m.cancel() + m.cancel = nil + m.state = repoCloneFailed + m.errText = "Clone canceled — nothing was left on disk. Press enter to try again." + return true +} + +func (m *RepoCloneModel) tick() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return repoCloneTickMsg{} }) +} + +// cloneFailureText renders a clone error the way the Welcome view talks: what +// happened, in git's own words, plus the next step. +func cloneFailureText(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.Canceled) { + return "Clone canceled — nothing was left on disk. Press enter to try again." + } + return err.Error() +} + +func (m *RepoCloneModel) View() string { + w := m.contentWidth() + + parts := []string{ + Title.Render("Clone from GitHub"), + Dim.Render(truncateRunes(m.ref.String(), w)), + "", + } + + switch m.state { + case repoCloneRunning: + frame := spinnerFrames[m.frame%len(spinnerFrames)] + parts = append(parts, + lipgloss.NewStyle().Foreground(ColorPrimary).Render(frame)+" "+ + truncateRunes("Cloning "+m.ref.Slug()+" into "+m.dest.Value(), w-2), + "", + Dim.Render("A big repo can take a minute."), + "", + HelpBar.Render(HelpKey.Render("esc")+" "+HelpDesc.Render("cancel")), + ) + default: + parts = append(parts, + Bold.Render("Clone into"), + m.dest.View(), + "", + ) + if m.errText != "" { + for _, line := range strings.Split(m.errText, "\n") { + parts = append(parts, Error.Render(Icon(IconWarningUnicode, IconWarningASCII)+" "+truncateRunes(line, w-2))) + } + parts = append(parts, "", Dim.Render("Fix the path above and press enter to try again.")) + } else if m.notice != "" { + style := Dim + if m.reuse { + style = Success + } + parts = append(parts, wrapNotice(style, m.notice, w)) + } + enterDesc := "clone" + if m.reuse { + enterDesc = "use it" + } + parts = append(parts, "", + HelpBar.Render( + HelpKey.Render("enter")+" "+HelpDesc.Render(enterDesc)+" "+ + HelpKey.Render("esc")+" "+HelpDesc.Render("back"))) + } + + panel := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorPrimary). + Padding(1, 2). + Width(w + 4). + Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, panel) +} + +// wrapNotice hard-wraps a sentence to the panel width so long destination +// paths don't blow the box open. +func wrapNotice(style lipgloss.Style, text string, width int) string { + return style.Width(width).Render(text) +} + +func (m *RepoCloneModel) contentWidth() int { + w := m.width - 10 + if w > 60 { + w = 60 + } + if w < 24 { + w = 24 + } + return w +} + +func (m *RepoCloneModel) layout() { + m.dest.Width = m.contentWidth() - lipgloss.Width(m.dest.Prompt) - 1 +} + +func (m *RepoCloneModel) SetSize(w, h int) { + m.width, m.height = w, h + m.layout() +} diff --git a/internal/ui/repoclone_flow_test.go b/internal/ui/repoclone_flow_test.go new file mode 100644 index 00000000..aeecba25 --- /dev/null +++ b/internal/ui/repoclone_flow_test.go @@ -0,0 +1,94 @@ +package ui + +import ( + "path/filepath" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/github" +) + +func newOnboardingTestModel(t *testing.T) *AppModel { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + return &AppModel{db: database, width: 100, height: 40} +} + +// TestOnboarding_PastedRepoURLOpensTheCloneView walks the first-run path a +// user takes: the folder picker, a pasted URL, enter. +func TestOnboarding_PastedRepoURLOpensTheCloneView(t *testing.T) { + m := newOnboardingTestModel(t) + m.currentView = ViewFolderPicker + m.folderPicker = NewFolderPickerModel(m.width, m.height) + + for _, r := range "https://github.com/bborn/taskyou" { + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app := updated.(*AppModel) + + // The picker asks for a clone; the app opens the clone view for it. + req, ok := cmd().(repoRequestedMsg) + if !ok { + t.Fatalf("want repoRequestedMsg from the picker, got %T", cmd()) + } + updated, _ = app.Update(req) + app = updated.(*AppModel) + + if app.currentView != ViewRepoClone || app.repoClone == nil { + t.Fatalf("view = %v, repoClone = %v — want the clone view", app.currentView, app.repoClone) + } + if app.folderPicker != nil { + t.Error("the folder picker should be closed while cloning") + } + if got := app.repoClone.ref.Slug(); got != "bborn/taskyou" { + t.Errorf("clone view is pointed at %q", got) + } +} + +// TestOnboarding_ClonedRepoRejoinsTheFolderPath is the point of the whole +// feature: once the clone lands, it is an ordinary picked folder. +func TestOnboarding_ClonedRepoRejoinsTheFolderPath(t *testing.T) { + m := newOnboardingTestModel(t) + repo := t.TempDir() + mkGitRepo(t, repo) + m.currentView = ViewRepoClone + m.repoClone = newRepoCloneModel(mustRef(t, "bborn/taskyou"), github.Cloner{Root: t.TempDir()}, m.width, m.height) + + updated, _ := m.Update(repoClonedMsg{path: repo}) + app := updated.(*AppModel) + + if app.currentView != ViewProjectDetectConfirm { + t.Fatalf("view = %v, want the ordinary project-confirm card", app.currentView) + } + if app.repoClone != nil { + t.Error("the clone view should be closed once the repo is on disk") + } + if app.detectedProject == nil || app.detectedProject.Path != repo { + t.Errorf("detected project = %+v, want one pointed at %s", app.detectedProject, repo) + } +} + +// TestOnboarding_EscFromTheCloneViewGoesBackToThePicker keeps the back door +// open: a mistyped URL shouldn't strand anyone. +func TestOnboarding_EscFromTheCloneViewGoesBackToThePicker(t *testing.T) { + m := newOnboardingTestModel(t) + m.currentView = ViewRepoClone + m.repoClone = newRepoCloneModel(mustRef(t, "bborn/taskyou"), github.Cloner{Root: t.TempDir()}, m.width, m.height) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + app := updated.(*AppModel) + + if app.currentView != ViewFolderPicker || app.folderPicker == nil { + t.Fatalf("view = %v — esc should return to the folder picker", app.currentView) + } + if app.repoClone != nil { + t.Error("the clone view should be discarded on the way back") + } +} diff --git a/internal/ui/repoclone_test.go b/internal/ui/repoclone_test.go new file mode 100644 index 00000000..691c5ef2 --- /dev/null +++ b/internal/ui/repoclone_test.go @@ -0,0 +1,234 @@ +package ui + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/bborn/workflow/internal/github" +) + +// fakeCloner builds a Cloner rooted in a temp dir whose clone step is a +// function under the test's control — nothing touches the network. +func fakeCloner(t *testing.T, run func(ctx context.Context, url, dest string) error) (github.Cloner, string) { + t.Helper() + root := t.TempDir() + return github.Cloner{ + Root: root, + RemoteURL: func(dir string) (string, error) { + data, err := os.ReadFile(filepath.Join(dir, ".git", "origin")) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil + }, + Run: run, + }, root +} + +// writeCheckout makes dir look like a checkout of remote to fakeCloner. +func writeCheckout(t *testing.T, dir, remote string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".git", "origin"), []byte(remote), 0o644); err != nil { + t.Fatal(err) + } +} + +func mustRef(t *testing.T, s string) github.RepoRef { + t.Helper() + ref, err := github.ParseRepoRef(s) + if err != nil { + t.Fatalf("ParseRepoRef(%q): %v", s, err) + } + return ref +} + +// drain executes the command chain a model returns, feeding every resulting +// message back into the model until a repoClonedMsg falls out (or the chain +// runs dry). Spinner ticks are noise here. +func drain(m *RepoCloneModel, cmd tea.Cmd) tea.Msg { + pending := []tea.Cmd{cmd} + for i := 0; len(pending) > 0 && i < 50; i++ { + next := pending[0] + pending = pending[1:] + if next == nil { + continue + } + msg := next() + switch typed := msg.(type) { + case tea.BatchMsg: + pending = append(pending, typed...) + continue + case repoClonedMsg: + return typed + case repoCloneTickMsg, nil: + continue + } + var cmd tea.Cmd + m, cmd = m.Update(msg) + pending = append(pending, cmd) + } + return nil +} + +func TestRepoClone_ShowsDestinationBeforeCloning(t *testing.T) { + cloner, root := fakeCloner(t, func(context.Context, string, string) error { + t.Fatal("clone should not run before the user confirms") + return nil + }) + m := newRepoCloneModel(mustRef(t, "https://github.com/bborn/taskyou"), cloner, 100, 40) + + if want := filepath.Join(root, "taskyou"); expandPath(m.dest.Value()) != want { + t.Errorf("destination = %q, want %q", m.dest.Value(), want) + } + view := m.View() + if !strings.Contains(view, "bborn/taskyou") || !strings.Contains(view, "taskyou") { + t.Errorf("view should name the repo and the destination, got:\n%s", view) + } +} + +func TestRepoClone_EnterClonesAndHandsBackThePath(t *testing.T) { + var cloned string + cloner, root := fakeCloner(t, func(_ context.Context, url, dest string) error { + if url != "https://github.com/bborn/taskyou.git" { + t.Errorf("clone url = %q", url) + } + cloned = dest + writeCheckout(t, dest, url) + return nil + }) + m := newRepoCloneModel(mustRef(t, "bborn/taskyou"), cloner, 100, 40) + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + msg := drain(m, cmd) + + done, ok := msg.(repoClonedMsg) + if !ok { + t.Fatalf("want repoClonedMsg, got %T", msg) + } + want := filepath.Join(root, "taskyou") + if done.path != want || cloned != want { + t.Errorf("cloned to %q / reported %q, want %q", cloned, done.path, want) + } +} + +func TestRepoClone_ExistingCloneIsAdoptedWithoutCloning(t *testing.T) { + cloner, root := fakeCloner(t, func(context.Context, string, string) error { + t.Fatal("an existing clone of the same repo must not be re-cloned") + return nil + }) + existing := filepath.Join(root, "taskyou") + writeCheckout(t, existing, "git@github.com:bborn/taskyou.git") + + m := newRepoCloneModel(mustRef(t, "https://github.com/bborn/taskyou"), cloner, 100, 40) + if !m.reuse { + t.Fatal("an existing checkout of the same repo should be offered for reuse") + } + if !strings.Contains(m.View(), "already have this repo") { + t.Errorf("view should say the repo is already there, got:\n%s", m.View()) + } + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + done, ok := drain(m, cmd).(repoClonedMsg) + if !ok { + t.Fatal("enter should hand back the existing path immediately") + } + if done.path != existing { + t.Errorf("path = %q, want %q", done.path, existing) + } +} + +func TestRepoClone_UnrelatedDirectoryGetsANonCollidingDestination(t *testing.T) { + cloner, root := fakeCloner(t, func(context.Context, string, string) error { return nil }) + if err := os.MkdirAll(filepath.Join(root, "taskyou", "src"), 0o755); err != nil { + t.Fatal(err) + } + + m := newRepoCloneModel(mustRef(t, "bborn/taskyou"), cloner, 100, 40) + + if want := filepath.Join(root, "taskyou-2"); expandPath(m.dest.Value()) != want { + t.Errorf("destination = %q, want %q", m.dest.Value(), want) + } + if !strings.Contains(m.View(), "taskyou-2") { + t.Errorf("view should show where the clone is actually going, got:\n%s", m.View()) + } +} + +func TestRepoClone_FailureSurfacesGitStderrAndStaysPut(t *testing.T) { + cloner, _ := fakeCloner(t, func(context.Context, string, string) error { + return &github.CloneError{ + Stderr: "Cloning into '/tmp/x'...\nremote: Repository not found.\nfatal: repository not found", + Err: errors.New("exit status 128"), + } + }) + m := newRepoCloneModel(mustRef(t, "bborn/nope"), cloner, 100, 40) + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if msg := drain(m, cmd); msg != nil { + t.Fatalf("a failed clone should not report a path, got %T", msg) + } + if m.state != repoCloneFailed { + t.Fatalf("state = %v, want failed", m.state) + } + view := m.View() + if !strings.Contains(view, "Repository not found") { + t.Errorf("view should show git's own words, got:\n%s", view) + } + if !strings.Contains(view, "try again") { + t.Errorf("view should say what to do next, got:\n%s", view) + } +} + +func TestRepoClone_EditingTheDestinationClearsAStaleError(t *testing.T) { + cloner, _ := fakeCloner(t, func(context.Context, string, string) error { + return errors.New("boom") + }) + m := newRepoCloneModel(mustRef(t, "bborn/taskyou"), cloner, 100, 40) + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _ = drain(m, cmd) + if m.state != repoCloneFailed { + t.Fatalf("state = %v, want failed", m.state) + } + + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + if m.state != repoCloneConfirm || m.errText != "" { + t.Errorf("editing the destination should clear the error, got state %v err %q", m.state, m.errText) + } +} + +func TestRepoClone_SpinnerRunsWhileCloning(t *testing.T) { + cloner, _ := fakeCloner(t, func(ctx context.Context, _, _ string) error { + <-ctx.Done() + return ctx.Err() + }) + m := newRepoCloneModel(mustRef(t, "bborn/taskyou"), cloner, 100, 40) + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if m.state != repoCloneRunning { + t.Fatalf("state = %v, want running", m.state) + } + first := m.View() + if !strings.Contains(first, "Cloning bborn/taskyou") { + t.Errorf("running view should name the repo, got:\n%s", first) + } + m, _ = m.Update(repoCloneTickMsg{}) + if m.View() == first { + t.Error("the spinner should advance on a tick — a still frame reads as a hung TUI") + } + + // esc cancels the in-flight clone rather than leaving it running. + if !m.Cancel() { + t.Fatal("Cancel should report that a clone was in flight") + } + if m.state != repoCloneFailed || !strings.Contains(m.errText, "canceled") { + t.Errorf("after cancel: state %v err %q", m.state, m.errText) + } +} diff --git a/internal/ui/welcome.go b/internal/ui/welcome.go index 9a069be0..39f6480e 100644 --- a/internal/ui/welcome.go +++ b/internal/ui/welcome.go @@ -67,7 +67,7 @@ func missingPrereqNotices(tmuxFound bool, agents []string) []string { // (the labels alone don't say "picks a folder" vs "no setup needed"). func welcomeChoiceHint(cursor int) string { if cursor == 0 { - return "Point TaskYou at a folder — tasks run against that codebase" + return "Point at a folder, or paste a GitHub URL to clone the repo first" } return "Start a task now in your personal space — no project setup" } From 60cd7f8f8f50f635159968713a7f2f5a78f01e2e Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 25 Aug 2026 22:13:32 -0500 Subject: [PATCH 2/2] fix(onboarding): keep the folder shelf and let clone messages wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA screenshots (scripts/qa/ty-qa-clone-shots.sh) caught three things the unit tests can't see: - Typing a repo URL filtered the folder list to nothing, so the picker read "No folders." — useless, and untrue. A URL isn't a filter term: leave the list alone and let the clone line do the talking. - The picker's inline parse error was truncated mid-example ("e.g. https://…"), which is the part that tells you what to type. Wrap it. - The clone view truncated git's stderr and the auth hint after it, so "gh auth login), or…" lost the ssh half of the fix. Wrap those too, flag only the first line, and give the panel 72 columns — git's messages are long. Also adds the screenshot script itself, and the TY_QA_SHOT_ENV hook in ty-qa-shoot.sh it needs to point HOME at a throwaway home, so a shot that clones for real never touches the real ~/Projects. Co-Authored-By: Claude Opus 5 --- internal/ui/folderpicker.go | 12 +++-- internal/ui/repoclone.go | 17 ++++-- scripts/qa/ty-qa-clone-shots.sh | 94 +++++++++++++++++++++++++++++++++ scripts/qa/ty-qa-shoot.sh | 10 ++++ 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100755 scripts/qa/ty-qa-clone-shots.sh diff --git a/internal/ui/folderpicker.go b/internal/ui/folderpicker.go index 6acc7614..1903b4d1 100644 --- a/internal/ui/folderpicker.go +++ b/internal/ui/folderpicker.go @@ -204,7 +204,10 @@ func (m *FolderPickerModel) Update(msg tea.Msg) (*FolderPickerModel, tea.Cmd) { m.input, cmd = m.input.Update(msg) if q := m.input.Value(); q != before { m.classifyInput(q) - if strings.TrimSpace(q) == "" { + // A repo URL isn't a filter term: filtering on one empties the list and + // the panel reads "No folders", which is both useless and untrue. Leave + // the folder shelf alone and let the clone line below do the talking. + if strings.TrimSpace(q) == "" || m.repoRef != nil || m.repoErr != "" { m.list.ResetFilter() m.list.ResetSelected() } else { @@ -310,11 +313,14 @@ func (m *FolderPickerModel) View() string { // countLine summarises what the list is showing, e.g. "18 folders · git repos // first", or "3 of 18 folders match" while a filter is active. func (m *FolderPickerModel) countLine() string { + w := m.contentWidth() if m.repoRef != nil { - return Success.Render(" " + Icon("⏺", "*") + " enter to clone " + m.repoRef.String()) + return Success.Width(w).PaddingLeft(2).Render("enter to clone " + m.repoRef.String()) } if m.repoErr != "" { - return Error.Render(" " + truncateRunes(m.repoErr, m.contentWidth()-2)) + // Wrapped, never truncated: the tail of these messages is the example + // URL, which is the whole point of showing them. + return Error.Width(w).PaddingLeft(2).Render(m.repoErr) } total := len(m.list.Items()) noun := "folders" diff --git a/internal/ui/repoclone.go b/internal/ui/repoclone.go index b5733de6..91d564ba 100644 --- a/internal/ui/repoclone.go +++ b/internal/ui/repoclone.go @@ -256,10 +256,17 @@ func (m *RepoCloneModel) View() string { "", ) if m.errText != "" { - for _, line := range strings.Split(m.errText, "\n") { - parts = append(parts, Error.Render(Icon(IconWarningUnicode, IconWarningASCII)+" "+truncateRunes(line, w-2))) + // Wrapped, not truncated: git's reason and the fix that follows it + // are both long, and the tail is the part that tells you what to do. + for i, line := range strings.Split(m.errText, "\n") { + // Only the first line is flagged; the rest wrap flush, since an + // indent under the icon falls apart the moment a line wraps. + if i == 0 { + line = Icon(IconWarningUnicode, IconWarningASCII) + " " + line + } + parts = append(parts, Error.Width(w).Render(line)) } - parts = append(parts, "", Dim.Render("Fix the path above and press enter to try again.")) + parts = append(parts, "", Dim.Width(w).Render("Fix the path above and press enter to try again.")) } else if m.notice != "" { style := Dim if m.reuse { @@ -294,8 +301,8 @@ func wrapNotice(style lipgloss.Style, text string, width int) string { func (m *RepoCloneModel) contentWidth() int { w := m.width - 10 - if w > 60 { - w = 60 + if w > 72 { + w = 72 } if w < 24 { w = 24 diff --git a/scripts/qa/ty-qa-clone-shots.sh b/scripts/qa/ty-qa-clone-shots.sh new file mode 100755 index 00000000..535056b9 --- /dev/null +++ b/scripts/qa/ty-qa-clone-shots.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Screenshot the "start a project from a GitHub repo URL" onboarding path +# against an isolated ty instance: Welcome fork -> folder picker -> clone view +# -> the ordinary project card. +# +# Every shot runs with HOME pointed at a throwaway home under $TY_QA_ROOT, so a +# clone lands in $TY_QA_ROOT/home/Projects and never touches the real ~/Projects. +# That fake home is also what the folder picker lists, so it is populated with +# believable repos (see the content standard in README.md). +# +# One shot clones for real (small public repo, a few seconds); the rest need no +# network. Nothing is written outside $TY_QA_ROOT. +# +# Usage: scripts/qa/ty-qa-clone-shots.sh [out-dir] (default $TY_QA_ROOT/shots) +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +OUT="${1:-$TY_QA_ROOT/shots}" +HOMEDIR="$TY_QA_ROOT/home" +PROJECTS="$HOMEDIR/Projects" +PLAIN="$TY_QA_ROOT/firstrun/just-a-folder" # no signals => Welcome fork +REPO_URL="https://github.com/charmbracelet/bubbletea" +REPO_NAME="bubbletea" + +echo "==> Building ty -> $TY_BIN" +( cd "$TY_REPO_ROOT" && go build -o "$TY_BIN" ./cmd/task ) + +# Only now: everything below runs against the throwaway home (the go build above +# would otherwise rebuild its cache inside it). +export HOME="$HOMEDIR" +export TY_QA_SHOT_ENV="HOME" + +echo "==> Throwaway home at $HOMEDIR" +rm -rf "$HOMEDIR" +mkdir -p "$PROJECTS" "$PLAIN" "$OUT" + +# The picker seeds from $HOME/Projects — give it a believable shelf of repos. +for p in storefront payments-api mobile-ios data-pipeline design-system; do + git -C "$PROJECTS" init -q "$p" + git -C "$PROJECTS/$p" config user.email qa@ty.local + git -C "$PROJECTS/$p" config user.name "ty qa" + printf '# %s\n' "$p" > "$PROJECTS/$p/README.md" + git -C "$PROJECTS/$p" add -A + git -C "$PROJECTS/$p" commit -qm init +done + +shoot() { # $1 = name, rest = tape lines + local name="$1"; shift + TY_QA_SHOT_W="${TY_QA_SHOT_W:-1180}" TY_QA_SHOT_H="${TY_QA_SHOT_H:-760}" \ + "$TY_QA_DIR/ty-qa-shoot.sh" "$PLAIN" "$OUT/$name.png" "$@" +} + +# Typing the URL a character at a time is VHS's only option; a real user pastes. +TYPE_URL="Type \"$REPO_URL\"" + +echo; echo "==> 1/8 welcome fork" +shoot welcome "Sleep 5s" + +echo; echo "==> 2/8 pasted URL becomes a clone offer" +shoot picker-paste "Sleep 5s" "Enter" "Sleep 1s" "$TYPE_URL" "Sleep 2s" + +echo; echo "==> 3/8 a URL that doesn't parse, inline" +shoot picker-error "Sleep 5s" "Enter" "Sleep 1s" 'Type "https://github.com/charmbracelet"' "Sleep 2s" + +echo; echo "==> 4/8 destination shown before cloning" +shoot clone-confirm "Sleep 5s" "Enter" "Sleep 1s" "$TYPE_URL" "Sleep 1s" "Enter" "Sleep 2s" + +echo; echo "==> 5/8 cloning (spinner)" +shoot clone-progress "Sleep 5s" "Enter" "Sleep 1s" "$TYPE_URL" "Sleep 1s" "Enter" "Sleep 500ms" "Enter" "Sleep 900ms" + +# The clone from shot 5 (if it finished) is the real thing; shot 6 waits it out +# and lands on the ordinary project card — the point of the whole feature. +echo; echo "==> 6/8 clone lands -> ordinary project card" +rm -rf "${PROJECTS:?}/$REPO_NAME" +shoot clone-done "Sleep 5s" "Enter" "Sleep 1s" "$TYPE_URL" "Sleep 1s" "Enter" "Sleep 500ms" "Enter" "Sleep 25s" + +echo; echo "==> 7/8 destination already holds this repo -> use it as-is" +rm -rf "${PROJECTS:?}/$REPO_NAME" +git -C "$PROJECTS" init -q "$REPO_NAME" +git -C "$PROJECTS/$REPO_NAME" remote add origin "$REPO_URL.git" +shoot clone-reuse "Sleep 5s" "Enter" "Sleep 1s" "$TYPE_URL" "Sleep 1s" "Enter" "Sleep 2s" + +echo; echo "==> 8/8 the name is taken by something else -> non-colliding destination" +rm -rf "${PROJECTS:?}/$REPO_NAME" +mkdir -p "$PROJECTS/$REPO_NAME/notes" +printf 'sketches\n' > "$PROJECTS/$REPO_NAME/notes/README.md" +shoot clone-collision "Sleep 5s" "Enter" "Sleep 1s" "$TYPE_URL" "Sleep 1s" "Enter" "Sleep 2s" + +echo; echo "==> bonus: clone fails -> git's own words, still in the UI" +shoot clone-failed "Sleep 5s" "Enter" "Sleep 1s" \ + 'Type "charmbracelet/no-such-repository"' "Sleep 1s" "Enter" "Sleep 500ms" "Enter" "Sleep 6s" + +echo; echo "==> shots in $OUT" +ls -1 "$OUT" diff --git a/scripts/qa/ty-qa-shoot.sh b/scripts/qa/ty-qa-shoot.sh index f1b6f0a0..ce06885e 100755 --- a/scripts/qa/ty-qa-shoot.sh +++ b/scripts/qa/ty-qa-shoot.sh @@ -15,6 +15,9 @@ # "Sleep 5s" "Enter" 'Type "ty"' "Sleep 2s" # (when omitted, the script waits 6s and screenshots) # +# TY_QA_SHOT_ENV="VAR1 VAR2" forwards those vars into the VHS terminal — for a +# scenario that must not touch the real machine (e.g. HOME). +# # Examples: # ty-qa-shoot.sh "$TY_QA_PROJECTS/demo" /tmp/card.png "Sleep 9s" # git-repo card (waits for claude -p inference) # ty-qa-shoot.sh /tmp/plain /tmp/welcome.png "Sleep 5s" # welcome fork @@ -63,6 +66,13 @@ GIF="${OUT%.png}.gif" if [ -n "${TY_ROUTINES_LAUNCHD_DIR:-}" ]; then echo "Env TY_ROUTINES_LAUNCHD_DIR \"$TY_ROUTINES_LAUNCHD_DIR\"" fi + # Anything else the scenario needs inside the headless terminal. VHS inherits + # the caller's environment, so a shot that must not touch the real machine + # (e.g. one that clones into $HOME/Projects) names the vars to override here: + # TY_QA_SHOT_ENV="HOME" HOME=/tmp/ty-qa/home ty-qa-shoot.sh … + for var in ${TY_QA_SHOT_ENV:-}; do + echo "Env $var \"${!var}\"" + done echo 'Hide' if [ -n "${TY_QA_SHOT_KEEP_DB:-}" ]; then # Keep the (seeded) DB — for board/detail shots with data.