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..1903b4d1 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,7 +203,11 @@ 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 { - if strings.TrimSpace(q) == "" { + m.classifyInput(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 { @@ -195,6 +217,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 +268,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 +313,15 @@ 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.Width(w).PaddingLeft(2).Render("enter to clone " + m.repoRef.String()) + } + if m.repoErr != "" { + // 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" 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..91d564ba --- /dev/null +++ b/internal/ui/repoclone.go @@ -0,0 +1,320 @@ +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 != "" { + // 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.Width(w).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 > 72 { + w = 72 + } + 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" } 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.