From fac96ee2ec6b4893076a44c4f1360a509ef90e8b Mon Sep 17 00:00:00 2001 From: "d3mlabs-ai-flow[bot]" <305891656+d3mlabs-ai-flow[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:52:14 -0400 Subject: [PATCH 1/2] ai-flow /build: dev clone: host-global builtin cloning via gh auth to the canonical $DEV_CD_ROOT path Co-authored-by: JPDuchesne <2636122+JPDuchesne@users.noreply.github.com> --- README.md | 18 ++- lib/dev/cd/hook_installer.rb | 34 ++++- lib/dev/clone.rb | 14 +++ lib/dev/clone/accessor.rb | 96 +++++++++++++++ lib/dev/clone/gh_cloner.rb | 49 ++++++++ lib/dev/clone/repo_spec.rb | 71 +++++++++++ src/dev/global_dispatch.rb | 20 ++- src/dev/runner.rb | 9 ++ test/dev/cd/hook_installer_test.rb | 38 +++++- test/dev/clone/accessor_test.rb | 191 +++++++++++++++++++++++++++++ test/dev/clone/gh_cloner_test.rb | 61 +++++++++ test/dev/clone/repo_spec_test.rb | 51 ++++++++ test/dev/global_dispatch_test.rb | 54 ++++++++ 13 files changed, 693 insertions(+), 13 deletions(-) create mode 100644 lib/dev/clone.rb create mode 100644 lib/dev/clone/accessor.rb create mode 100644 lib/dev/clone/gh_cloner.rb create mode 100644 lib/dev/clone/repo_spec.rb create mode 100644 test/dev/clone/accessor_test.rb create mode 100644 test/dev/clone/gh_cloner_test.rb create mode 100644 test/dev/clone/repo_spec_test.rb diff --git a/README.md b/README.md index 90b0739..3090be3 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ dev # List all available commands The tool walks up from your current directory until it finds a git repo root (directory containing `.git`), then looks for `dev.yml` there. If found, it parses the commands and executes the `run` string for your chosen subcommand. -A few builtins are global and work from **any** directory, no `dev.yml` needed: `dev cd` (host-global navigation), `dev cred` (host-global credentials), and `dev plan` (workspace-global plan sync). Project commands (`dev up` and anything declared in `dev.yml`) still require a nearby `dev.yml`. +A few builtins are global and work from **any** directory, no `dev.yml` needed: `dev cd` (host-global navigation), `dev clone` (host-global checkout creation), `dev cred` (host-global credentials), and `dev plan` (workspace-global plan sync). Project commands (`dev up` and anything declared in `dev.yml`) still require a nearby `dev.yml`. ## dev cd — jump between checkouts @@ -123,9 +123,22 @@ export DEV_CD_ROOT=/path/to/checkouts Only git repos count as candidates (directories with a `.git` entry — a `.git` file from a worktree checkout works too); plain folders are skipped. The query is a right-anchored path suffix matched per segment: `dev` matches the leaf, `d3mlabs/dev` the org and leaf, `bitbucket.org/d3mlabs/dev` the host too — a more explicit path always works. On an ambiguous query, `dev cd` lists the candidates (each at the shortest depth that makes it unique, capped at 10) and exits non-zero; refine the query or press Tab to browse all matches. On no match it errors clearly. +## dev clone — clone into the canonical layout + +`dev clone [/]` clones a GitHub repo (via your `gh` auth — no credentials of dev's own) into the canonical checkout path under the same search root `dev cd` walks — `$DEV_CD_ROOT/github.com//`, default `~/src` — and lands your shell in the fresh checkout through the same wrapper: + +```bash +dev clone myrepo # org defaults to d3mlabs → ~/src/github.com/d3mlabs/myrepo +dev clone acme/widget # explicit org +``` + +It is clone-only by design — no automatic `dev up`. Provisioning stays a deliberate second step, because a first `dev up` is where credential prompts happen and you should see them coming. The fresh-machine story is three commands: `brew install d3mlabs/d3mlabs/dev` → `dev clone ` → `dev up`. + +If the canonical destination already exists, `dev clone` errors and points you at `dev cd`. Without the shell wrapper active (e.g. the very first dev command on a fresh machine), the clone still happens; dev installs the hook for next time and prints the destination instead of jumping there. + ### Shell hook install -`dev cd` needs a small shell wrapper — a Ruby child process cannot change your shell's directory. dev installs the wrapper function and Tab completers into your shell RC automatically and idempotently: on `dev up` in any project, and on `dev cd` itself (so a first `dev cd` self-heals the hook; open a new shell after the install hint). The snippet is marker-guarded (`# dev cd (added by dev)`) next to the shadowenv one, and re-runs never duplicate it. +`dev cd` and the landing half of `dev clone` need a small shell wrapper — a Ruby child process cannot change your shell's directory. dev installs the wrapper function and Tab completers into your shell RC automatically and idempotently: on `dev up` in any project, and on `dev cd` / a hook-less `dev clone` themselves (so a first use self-heals the hook; open a new shell after the install hint). The snippet is marker-guarded (`# dev cd + clone (added by dev)`) next to the shadowenv one, and re-runs never duplicate it; when the snippet itself evolves, the marker changes with it and the next ensure appends the updated wrapper, whose later definition wins. Tab completion is registered per shell: zsh gets a navigable menu-select list scoped to the `dev` command only (your other commands' completion is untouched; registration is skipped quietly if your zshrc never runs `compinit`), bash fills `COMPREPLY` directly, and fish registers a standard pager completion (fish applies its own filtering, so fuzzy tokens may only complete literally there). Completion fills the argument only — it never runs the `cd` for you — and inserts `org/repo` (or deeper) forms when a short name would collide. @@ -360,6 +373,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`dev deps path `** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, or `dev deps path xcode` for the pinned DEVELOPER_DIR) so scripts don't reconstruct cache keys or layout conventions. - **`dev cred get `** — resolve a credential through the provider chain (ENV → keychain → file → prompt) and print it. A non-interactive miss errors with `gh secret set` guidance. Mirrors `dev deps path` for shell consumers (e.g. a staging sync). Global: works without a `dev.yml`. - **`dev cd `** — jump to a checkout under `$DEV_CD_ROOT` (default `~/src`) by fuzzy name, with Tab completion (see [dev cd](#dev-cd--jump-between-checkouts)). Global: works without a `dev.yml`. +- **`dev clone [/]`** — clone a GitHub repo via your `gh` auth into the canonical `$DEV_CD_ROOT/github.com//` path (org defaults to `d3mlabs`) and land there (see [dev clone](#dev-clone--clone-into-the-canonical-layout)). Clone-only — run `dev up` yourself. Global: works without a `dev.yml`. - **`dev cache gc [--keep N]`** — reclaim host caches dev owns (see below). - **`dev reset-container`** — remove the persistent build container (clears its incremental cache); registered only when `build.container.persist` is set. - **`dev plan …`** — global (works without a `dev.yml`; the workspace is the nearest dev.yml or git root). Sync Cursor plans with GitHub issues (ai-flow): the issue is the canonical plan, the local `.cursor/plans/gh--.plan.md` is a transient working copy carrying an `` header. Subcommands: `new "" [--org]` (create issue + linked plan; `--org` scaffolds a `Target repos:` line), `link <n> [<file>]` / `link <file>` (attach a draft to an existing issue / create one from it), `pull <n> [--merge]` (fetch, 3-way merging when both sides changed — the merge base lives at `~/.local/state/ai-flow/`), `push [<file>|<n>]` (guarded body PATCH — refuses to clobber newer remote edits; a number resolves the linked plan like `pull`), and `status` (clean / ahead / behind / diverged, per linked plan). `--org` targets the org plans repo (`plans_repo:` in `~/.config/dev/config.yml`, or `DEV_PLANS_REPO`) instead of the current repo's origin. Every invocation also refreshes the user-global links for dev's shipped skills (`share/cursor-skills/*` → `~/.cursor/skills/`, so the Cursor agent knows these verbs) and the org learnings artifacts (see [Agent skills & org learnings](#agent-skills--org-learnings)). For auto-push, a participating repo adds a Cursor `afterFileEdit` hook to `.cursor/hooks.json` running `dev plan hook-after-edit` — it reads the hook payload from stdin and no-ops unless the edited file is a linked plan. What happens to a plan after it's canonical — `/ask`, `/edit`, `/split` (two-phase dry/apply), `/build` — is ai-flow's remote half: see [plan-lifecycle.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/plan-lifecycle.md) and [commands.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/commands.md). diff --git a/lib/dev/cd/hook_installer.rb b/lib/dev/cd/hook_installer.rb index f57d3bd..0f3dcb4 100644 --- a/lib/dev/cd/hook_installer.rb +++ b/lib/dev/cd/hook_installer.rb @@ -4,13 +4,16 @@ module Dev module Cd - # Installs the `dev cd` shell hook through the shared RC-hook installer. + # Installs the `dev cd` / `dev clone` shell hook through the shared + # RC-hook installer. # # The hook is a `dev()` wrapper function (a Ruby child cannot change the - # parent shell's cwd): it intercepts `dev cd …`, resolves the target via - # the hidden `--resolve` plumbing, and `builtin cd`s into it in the - # current shell — so chpwd hooks (e.g. shadowenv) fire exactly as they - # would for a manual `cd`. Everything else falls through to `command dev`. + # parent shell's cwd): it intercepts `dev cd …` and `dev clone …`, + # resolves the target directory via the hidden plumbing modes (`cd + # --resolve` matches an existing checkout; `clone --path` clones then + # prints the destination), and `builtin cd`s into it in the current + # shell — so chpwd hooks (e.g. shadowenv) fire exactly as they would for + # a manual `cd`. Everything else falls through to `command dev`. # # Each snippet also registers Tab completion backed by `--candidates`. # Completion replaces the typed token with the rendered candidate (fuzzy @@ -20,7 +23,11 @@ module Cd # registers a `complete -c dev` source (fish applies its own filtering, # so fuzzy tokens may complete only literally there). class HookInstaller - MARKER = "# dev cd (added by dev)" + # The marker names the snippet generation: RCs carrying only an older + # marker get the current snippet appended on the next ensure (dev up or + # any `dev cd`), and the later function definition wins in every + # supported shell — self-healing updates without RC surgery. + MARKER = "# dev cd + clone (added by dev)" ZSH_SNIPPET = <<~'SNIPPET' dev() { @@ -29,6 +36,11 @@ class HookInstaller local __dev_cd_target __dev_cd_target="$(command dev cd --resolve "$@")" || return $? builtin cd -- "$__dev_cd_target" + elif [[ "$1" == clone ]]; then + shift + local __dev_clone_target + __dev_clone_target="$(command dev clone --path "$@")" || return $? + builtin cd -- "$__dev_clone_target" else command dev "$@" fi @@ -53,6 +65,11 @@ class HookInstaller local __dev_cd_target __dev_cd_target="$(command dev cd --resolve "$@")" || return $? builtin cd -- "$__dev_cd_target" + elif [ "$1" = clone ]; then + shift + local __dev_clone_target + __dev_clone_target="$(command dev clone --path "$@")" || return $? + builtin cd -- "$__dev_clone_target" else command dev "$@" fi @@ -74,6 +91,11 @@ class HookInstaller set -l __dev_cd_target (command dev cd --resolve $argv) or return $status builtin cd $__dev_cd_target + else if test (count $argv) -ge 1; and test "$argv[1]" = clone + set -e argv[1] + set -l __dev_clone_target (command dev clone --path $argv) + or return $status + builtin cd $__dev_clone_target else command dev $argv end diff --git a/lib/dev/clone.rb b/lib/dev/clone.rb new file mode 100644 index 0000000..c0b7e73 --- /dev/null +++ b/lib/dev/clone.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "dev/clone/repo_spec" +require "dev/clone/gh_cloner" +require "dev/clone/accessor" + +module Dev + # `dev clone`: clone a GitHub repo via the user's gh auth into the canonical + # checkout layout under $DEV_CD_ROOT (default ~/src), landing the shell in + # the fresh checkout through the same wrapper that powers `dev cd`. See + # Dev::Clone::Accessor for the command surface. + module Clone + end +end diff --git a/lib/dev/clone/accessor.rb b/lib/dev/clone/accessor.rb new file mode 100644 index 0000000..f72f43a --- /dev/null +++ b/lib/dev/clone/accessor.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "pathname" +require "dev/clone/repo_spec" +require "dev/clone/gh_cloner" +require "dev/cd/hook_installer" + +module Dev + module Clone + # Dispatch for `dev clone …` — clone a GitHub repo via the user's gh auth + # into the canonical checkout layout under the search root ($DEV_CD_ROOT, + # default ~/src): `github.com/<org>/<repo>`. Clone only — no `dev up`: + # provisioning stays a deliberate second step, where credential prompts + # are expected. + # + # The human command is `dev clone [<org>/]<repo>` (org defaults to + # d3mlabs), handled by the installed shell wrapper (the same one that + # powers `dev cd`); the wrapper calls the hidden plumbing mode: + # + # - `--path [<org>/]<repo>`: clone, then print exactly the destination's + # absolute path on stdout (the wrapper `builtin cd`s into it) + # + # A bare `dev clone …` reaching this process means the wrapper isn't + # active in the calling shell. Unlike `dev cd`, the clone still happens — + # it is the useful work, and on a fresh machine `dev clone` runs before + # any hook exists — then the hook self-heals and the destination is + # explained instead of landed in. + class Accessor + # `dev clone` was invoked with the wrong arguments. + class UsageError < RuntimeError; end + + # The canonical destination already exists on disk. + class DestinationExistsError < RuntimeError; end + + # @param root [String, Pathname] checkout root (default: $DEV_CD_ROOT, else ~/src) + # @param cloner [Dev::Clone::GhCloner] + # @param hook_installer [Dev::Cd::HookInstaller] + def initialize(root: ENV["DEV_CD_ROOT"] || (Pathname(Dir.home) / "src"), + cloner: GhCloner.new, hook_installer: Dev::Cd::HookInstaller.new) + @root = Pathname(root).expand_path + @cloner = cloner + @hook_installer = hook_installer + end + + # Dispatch a `dev clone …` invocation. + # + # @param args [Array<String>] argv after the "clone" command + # @param out [IO] stdout (the machine-readable payload only) + # @param err [IO] stderr (progress, diagnostics and hints) + # @return [void] + # @raise [UsageError] unless exactly one clone target is given + # @raise [RepoSpec::MalformedRepoError] when the target isn't "<repo>" or "<org>/<repo>" + # @raise [DestinationExistsError] when the canonical path already exists + # @raise [GhCloner::CloneFailedError] when the clone itself fails + def run(args, out: $stdout, err: $stderr) + plumbing = args.first == "--path" + query = plumbing ? args.drop(1) : args + raise UsageError, "usage: dev clone [<org>/]<repo>" unless query.size == 1 + + spec = RepoSpec.parse(query.fetch(0)) + destination = @root / spec.relative_path + if destination.exist? + raise DestinationExistsError, "#{destination} already exists — jump there with `dev cd #{spec.name}`" + end + + @cloner.clone(spec.full_name, destination) + plumbing ? out.puts(destination) : announce(spec, destination, err:) + end + + private + + # Report a hook-less clone: where it landed, and how to get the landing + # behavior next time. Also self-heals the hook — a fresh machine's first + # `dev clone` runs before any `dev up` had a chance to install it. + # + # @param spec [Dev::Clone::RepoSpec] + # @param destination [Pathname] + # @param err [IO] + # @return [void] + def announce(spec, destination, err:) + err.puts "dev: cloned #{spec.full_name} to #{destination}" + case @hook_installer.ensure_installed + when :added + err.puts "dev: shell hook installed — open a new shell and `dev clone` will land you in the checkout. " \ + "For now: cd #{destination}" + when :already_present + err.puts "dev: the dev shell hook is installed but not active in this shell — open a new shell " \ + "(or source your shell RC). For now: cd #{destination}" + else + err.puts "dev: your shell is unsupported for hooks (supported: zsh, bash, fish). " \ + "Jump there with: cd #{destination}" + end + end + end + end +end diff --git a/lib/dev/clone/gh_cloner.rb b/lib/dev/clone/gh_cloner.rb new file mode 100644 index 0000000..327622d --- /dev/null +++ b/lib/dev/clone/gh_cloner.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module Dev + module Clone + # Clones GitHub repos through `gh`, so the fetch rides the user's gh auth + # (dev is public and carries no credentials of its own — the same doctrine + # as the learnings cache). + class GhCloner + # `gh repo clone` failed (gh missing, unauthenticated, or a git error). + class CloneFailedError < RuntimeError; end + + # Thin wrapper over the gh CLI boundary. Tests inject a fake. + class Executor + # Run argv streaming its output, with the child's stdout redirected + # to stderr: clone progress belongs with diagnostics, and `dev clone`'s + # stdout is reserved for the machine payload (the destination path the + # shell wrapper cds into). + # + # @param argv [Array<String>] + # @return [Boolean] whether the command exited 0 + def system(*argv) + Kernel.system(*argv, out: $stderr) ? true : false + end + end + + # @param executor [Executor] CLI boundary (injectable for tests) + def initialize(executor: Executor.new) + @executor = executor + end + + # Clone full_name into destination, creating parent directories first + # (the canonical layout's host/org levels may not exist yet). + # + # @param full_name [String] "owner/repo" + # @param destination [Pathname] the target checkout directory + # @return [void] + # @raise [CloneFailedError] when the clone exits non-zero + def clone(full_name, destination) + FileUtils.mkdir_p(destination.dirname) + return if @executor.system("gh", "repo", "clone", full_name, destination.to_s) + + raise CloneFailedError, "gh repo clone #{full_name} failed — is gh authenticated? (gh auth login)" + end + end + end +end diff --git a/lib/dev/clone/repo_spec.rb b/lib/dev/clone/repo_spec.rb new file mode 100644 index 0000000..1950509 --- /dev/null +++ b/lib/dev/clone/repo_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "pathname" + +module Dev + module Clone + # The parsed target of a `dev clone` invocation. + # + # Accepts "<repo>" (org defaults to d3mlabs) or "<org>/<repo>". The host + # is always github.com — clones ride the user's gh auth, and the + # canonical checkout layout under the search root is host/org/repo. + # + # A plain value class rather than Data.define: constants declared inside + # a define block land on the enclosing module (breaking the nested typed + # error), and Sorbet rejects the `class X < Data.define` form. + class RepoSpec + # The argument is not a "<repo>" or "<org>/<repo>" clone target. + class MalformedRepoError < RuntimeError; end + + DEFAULT_ORG = "d3mlabs" + HOST = "github.com" + + # GitHub owner/repo name characters: word chars, dots, hyphens. + SEGMENT_PATTERN = /\A[\w.-]+\z/ + + # @return [String] + attr_reader :org, :name + + class << self + # Parse a clone target argument into a spec. + # + # @param arg [String] "<repo>" or "<org>/<repo>" + # @return [Dev::Clone::RepoSpec] + # @raise [MalformedRepoError] when the argument is not one or two + # valid path segments + def parse(arg) + # -1 keeps trailing empty segments, so "repo/" fails validation + # instead of silently collapsing to "repo". + segments = arg.split("/", -1) + unless (1..2).cover?(segments.size) && segments.all? { |segment| segment.match?(SEGMENT_PATTERN) } + raise MalformedRepoError, "expected <repo> or <org>/<repo>, got '#{arg}'" + end + + org, name = segments.size == 2 ? segments : [DEFAULT_ORG, segments.fetch(0)] + new(org:, name:) + end + end + + # @param org [String] the GitHub owner + # @param name [String] the repo name + def initialize(org:, name:) + @org = org + @name = name + end + + # The gh clone target. + # + # @return [String] "org/repo" + def full_name + "#{org}/#{name}" + end + + # The canonical checkout location relative to the search root. + # + # @return [Pathname] "github.com/<org>/<repo>" + def relative_path + Pathname(HOST) / org / name + end + end + end +end diff --git a/src/dev/global_dispatch.rb b/src/dev/global_dispatch.rb index 42095f6..e570dae 100644 --- a/src/dev/global_dispatch.rb +++ b/src/dev/global_dispatch.rb @@ -3,6 +3,7 @@ require "pathname" require "dev/cd" +require "dev/clone" require "dev/plan" require "dev/learnings" require "dev/credentials" @@ -13,6 +14,9 @@ module Dev # # - `dev cd` — host-global (jumps between checkouts; also its hidden # --resolve / --candidates plumbing) + # - `dev clone` — host-global (clones into the canonical checkout layout + # under $DEV_CD_ROOT; on a fresh machine it runs before + # any project exists) # - `dev cred` — host-global (credentials live under XDG / ~/.config/dev) # - `dev plan` — workspace-global (plans live in the enclosing # workspace, no project config is read) @@ -25,16 +29,25 @@ module Dev class GlobalDispatch extend T::Sig - GLOBAL_COMMANDS = T.let(%w[cd plan cred learnings].freeze, T::Array[String]) + GLOBAL_COMMANDS = T.let(%w[cd clone plan cred learnings].freeze, T::Array[String]) # Candidates shown in an ambiguous `dev cd` error before truncating. AMBIGUOUS_CANDIDATE_CAP = 10 # @param cd_accessor [Dev::Cd::Accessor] + # @param clone_accessor [Dev::Clone::Accessor] # @param cred_accessor [Dev::CredentialAccessor] - sig { params(cd_accessor: Dev::Cd::Accessor, cred_accessor: Dev::CredentialAccessor).void } - def initialize(cd_accessor: Dev::Cd::Accessor.new, cred_accessor: Dev::CredentialAccessor.new) + sig do + params( + cd_accessor: Dev::Cd::Accessor, + clone_accessor: Dev::Clone::Accessor, + cred_accessor: Dev::CredentialAccessor, + ).void + end + def initialize(cd_accessor: Dev::Cd::Accessor.new, clone_accessor: Dev::Clone::Accessor.new, + cred_accessor: Dev::CredentialAccessor.new) @cd_accessor = T.let(cd_accessor, Dev::Cd::Accessor) + @clone_accessor = T.let(clone_accessor, Dev::Clone::Accessor) @cred_accessor = T.let(cred_accessor, Dev::CredentialAccessor) end @@ -58,6 +71,7 @@ def run(argv) cmd_name = T.must(args.shift) case cmd_name when "cd" then @cd_accessor.run(args) + when "clone" then @clone_accessor.run(args) # Plan and Learnings accessors are built per run: their workspace root # depends on the cwd. when "plan" then Dev::Plan::Accessor.new(project_root: workspace_root).run(args) diff --git a/src/dev/runner.rb b/src/dev/runner.rb index febc1ed..b0c06b3 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -25,6 +25,7 @@ require 'dev/runner_setup' require 'dev/cli/ui' require 'dev/cd' +require 'dev/clone' require 'build_container' require 'shadowenv_ruby' @@ -286,6 +287,14 @@ def register_builtins(registry) Dev::Cd::Accessor.new.run(args) end) + # `dev clone` is dispatched globally (before dev.yml lookup) in bin/dev; + # this registration only surfaces it in `dev --help`. + registry.register("clone", BuiltinCommand.new( + desc: "Clone a GitHub repo (via gh auth) into $DEV_CD_ROOT (default ~/src), org defaults to d3mlabs", + ) do |args, _context| + Dev::Clone::Accessor.new.run(args) + end) + # `dev learnings` is dispatched globally in bin/dev, like cd; this # registration only surfaces it in `dev --help`. registry.register("learnings", BuiltinCommand.new( diff --git a/test/dev/cd/hook_installer_test.rb b/test/dev/cd/hook_installer_test.rb index e5ce344..f59d006 100644 --- a/test/dev/cd/hook_installer_test.rb +++ b/test/dev/cd/hook_installer_test.rb @@ -20,8 +20,9 @@ class Dev::Cd::HookInstallerTest < Minitest::Test Then "the wrapper, completer and dev-scoped menu-select are installed" result == :added content = File.read(File.join(home, ".zshrc")) - assert_includes content, "# dev cd (added by dev)" + assert_includes content, "# dev cd + clone (added by dev)" assert_includes content, 'command dev cd --resolve "$@"' + assert_includes content, 'command dev clone --path "$@"' assert_includes content, "builtin cd" assert_includes content, "compadd -U" assert_includes content, "zstyle ':completion:*:*:dev:*' menu select" @@ -76,6 +77,7 @@ class Dev::Cd::HookInstallerTest < Minitest::Test result == :added content = File.read(File.join(home, ".bash_profile")) assert_includes content, 'command dev cd --resolve "$@"' + assert_includes content, 'command dev clone --path "$@"' assert_includes content, "COMPREPLY=($(command dev cd --candidates" assert_includes content, "complete -F _dev_cd_completion dev" refute_includes content, "compgen -W" @@ -97,6 +99,7 @@ class Dev::Cd::HookInstallerTest < Minitest::Test content = File.read(File.join(home, ".config", "fish", "config.fish")) assert_includes content, "function dev" assert_includes content, "command dev cd --resolve $argv" + assert_includes content, "command dev clone --path $argv" assert_includes content, "complete -c dev" Cleanup @@ -129,7 +132,38 @@ class Dev::Cd::HookInstallerTest < Minitest::Test result == :added content = File.read(File.join(home, ".zshrc")) assert_includes content, "# Shadowenv (added by dev)" - assert_includes content, "# dev cd (added by dev)" + assert_includes content, "# dev cd + clone (added by dev)" + + Cleanup + FileUtils.rm_rf(home) + end + + test "an RC carrying the pre-clone snippet self-heals to the current wrapper" do + Given "a zsh user with the old cd-only snippet under its old marker" + home = Dir.mktmpdir("cd-hook-test-") + File.write(File.join(home, ".zshrc"), <<~RC) + # dev cd (added by dev) + dev() { + if [[ "$1" == cd ]]; then + shift + local __dev_cd_target + __dev_cd_target="$(command dev cd --resolve "$@")" || return $? + builtin cd -- "$__dev_cd_target" + else + command dev "$@" + fi + } + RC + installer = build_installer(shell: "/bin/zsh", home: home) + + When "we ensure the hook" + result = installer.ensure_installed + + Then "the current wrapper is appended (its later definition wins in the shell)" + result == :added + content = File.read(File.join(home, ".zshrc")) + assert_includes content, "# dev cd + clone (added by dev)" + assert_includes content, 'command dev clone --path "$@"' Cleanup FileUtils.rm_rf(home) diff --git a/test/dev/clone/accessor_test.rb b/test/dev/clone/accessor_test.rb new file mode 100644 index 0000000..6028d39 --- /dev/null +++ b/test/dev/clone/accessor_test.rb @@ -0,0 +1,191 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/clone" +require "fileutils" +require "stringio" +require "tmpdir" + +# A cloner stand-in recording its calls, so accessor flows are tested without +# gh or the network. +class FakeGhCloner + attr_reader :calls + + def initialize + @calls = [] + end + + def clone(full_name, destination) + @calls << [full_name, destination.to_s] + end +end unless defined?(FakeGhCloner) + +# A hook installer stand-in with a scripted ensure result, so accessor flows +# are tested without touching the user's real shell RC. +class FakeCloneHookInstaller + attr_reader :ensure_count + + def initialize(result: :already_present) + @result = result + @ensure_count = 0 + end + + def ensure_installed + @ensure_count += 1 + @result + end +end unless defined?(FakeCloneHookInstaller) + +transform!(RSpock::AST::Transformation) +class Dev::Clone::AccessorTest < Minitest::Test + test "--path clones and prints exactly the destination's absolute path on stdout" do + Given "an empty checkout root" + root = Dir.mktmpdir("clone-accessor-") + cloner = FakeGhCloner.new + installer = FakeCloneHookInstaller.new + accessor = build_accessor(root, cloner: cloner, hook_installer: installer) + out = StringIO.new + err = StringIO.new + + When "we clone via the wrapper's plumbing mode" + accessor.run(["--path", "acme/widget"], out: out, err: err) + + Then "the clone landed at the canonical path, printed as the only stdout line" + expected = File.join(File.expand_path(root), "github.com", "acme", "widget") + cloner.calls == [["acme/widget", expected]] + out.string == "#{expected}\n" + err.string == "" + installer.ensure_count == 0 + + Cleanup + FileUtils.rm_rf(root) + end + + test "a bare repo name defaults the org to d3mlabs" do + Given "an empty checkout root" + root = Dir.mktmpdir("clone-accessor-") + cloner = FakeGhCloner.new + accessor = build_accessor(root, cloner: cloner) + + When "we clone by leaf name" + accessor.run(["--path", "dev"], out: StringIO.new, err: StringIO.new) + + Then "the clone targets d3mlabs/dev at the canonical path" + cloner.calls == [["d3mlabs/dev", File.join(File.expand_path(root), "github.com", "d3mlabs", "dev")]] + + Cleanup + FileUtils.rm_rf(root) + end + + test "a bare invocation still clones, keeps stdout empty, and explains the destination" do + Given "an empty checkout root and an installed-but-inactive hook" + root = Dir.mktmpdir("clone-accessor-") + cloner = FakeGhCloner.new + installer = FakeCloneHookInstaller.new(result: :already_present) + accessor = build_accessor(root, cloner: cloner, hook_installer: installer) + out = StringIO.new + err = StringIO.new + + When "we clone without the wrapper" + accessor.run(["d3mlabs/dev"], out: out, err: err) + + Then "the clone happened, the hook was ensured, and stderr carries the story" + cloner.calls.size == 1 + out.string == "" + installer.ensure_count == 1 + assert_includes err.string, "cloned d3mlabs/dev to" + assert_includes err.string, "not active in this shell" + + Cleanup + FileUtils.rm_rf(root) + end + + test "a bare invocation that just installed the hook hints at a new shell" do + Given "a hook installer that reports :added" + root = Dir.mktmpdir("clone-accessor-") + accessor = build_accessor(root, hook_installer: FakeCloneHookInstaller.new(result: :added)) + err = StringIO.new + + When "we clone without the wrapper" + accessor.run(["dev"], out: StringIO.new, err: err) + + Then "the fresh-install hint is printed" + assert_includes err.string, "shell hook installed" + assert_includes err.string, "open a new shell" + + Cleanup + FileUtils.rm_rf(root) + end + + test "a bare invocation on an unsupported shell names the supported set" do + Given "a hook installer that refuses" + root = Dir.mktmpdir("clone-accessor-") + accessor = build_accessor(root, hook_installer: FakeCloneHookInstaller.new(result: false)) + err = StringIO.new + + When "we clone without the wrapper" + accessor.run(["dev"], out: StringIO.new, err: err) + + Then "the supported shells are named" + assert_includes err.string, "zsh, bash, fish" + + Cleanup + FileUtils.rm_rf(root) + end + + test "an existing destination raises DestinationExistsError without cloning" do + Given "the canonical path already checked out" + root = Dir.mktmpdir("clone-accessor-") + FileUtils.mkdir_p(File.join(root, "github.com", "d3mlabs", "dev")) + cloner = FakeGhCloner.new + accessor = build_accessor(root, cloner: cloner) + + When "we clone the same repo" + error = assert_raises(Dev::Clone::Accessor::DestinationExistsError) do + accessor.run(["--path", "dev"], out: StringIO.new, err: StringIO.new) + end + + Then "the error points at dev cd and no clone ran" + assert_includes error.message, "dev cd dev" + cloner.calls == [] + + Cleanup + FileUtils.rm_rf(root) + end + + test "#{description} raises UsageError" do + Given "an accessor" + accessor = build_accessor(Dir.tmpdir) + + When "we run it" + accessor.run(args, out: StringIO.new, err: StringIO.new) + + Then + raises Dev::Clone::Accessor::UsageError + + Where + args | description + [] | "no target" + ["a", "b"] | "two targets" + ["--path"] | "plumbing with no target" + ["--path", "a", "b"] | "plumbing with two targets" + end + + test "a malformed target surfaces RepoSpec's typed error" do + Given "an accessor" + accessor = build_accessor(Dir.tmpdir) + + When "we clone a nonsense target" + accessor.run(["a/b/c"], out: StringIO.new, err: StringIO.new) + + Then + raises Dev::Clone::RepoSpec::MalformedRepoError + end + + private + + def build_accessor(root, cloner: FakeGhCloner.new, hook_installer: FakeCloneHookInstaller.new) + Dev::Clone::Accessor.new(root: root, cloner: cloner, hook_installer: hook_installer) + end +end diff --git a/test/dev/clone/gh_cloner_test.rb b/test/dev/clone/gh_cloner_test.rb new file mode 100644 index 0000000..c21e8b8 --- /dev/null +++ b/test/dev/clone/gh_cloner_test.rb @@ -0,0 +1,61 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/clone" +require "fileutils" +require "pathname" +require "tmpdir" + +# An executor stand-in over the gh CLI boundary, recording argv and answering +# a scripted exit status — the one true boundary this module has. +class RecordingCloneExecutor + attr_reader :argvs + + def initialize(success: true) + @success = success + @argvs = [] + end + + def system(*argv) + @argvs << argv + @success + end +end unless defined?(RecordingCloneExecutor) + +transform!(RSpock::AST::Transformation) +class Dev::Clone::GhClonerTest < Minitest::Test + test "clones through gh into the destination, creating parent directories" do + Given "a destination whose host/org levels don't exist yet" + root = Dir.mktmpdir("gh-cloner-") + destination = Pathname(root) / "github.com" / "acme" / "widget" + executor = RecordingCloneExecutor.new + cloner = Dev::Clone::GhCloner.new(executor: executor) + + When "we clone" + cloner.clone("acme/widget", destination) + + Then "gh repo clone ran with the full name and path, and the parents exist" + executor.argvs == [["gh", "repo", "clone", "acme/widget", destination.to_s]] + File.directory?(destination.dirname) + + Cleanup + FileUtils.rm_rf(root) + end + + test "a failing gh clone raises CloneFailedError" do + Given "an executor whose gh invocation fails" + root = Dir.mktmpdir("gh-cloner-") + destination = Pathname(root) / "github.com" / "acme" / "widget" + cloner = Dev::Clone::GhCloner.new(executor: RecordingCloneExecutor.new(success: false)) + + When "we clone" + cloner.clone("acme/widget", destination) + + Then + raises Dev::Clone::GhCloner::CloneFailedError + + Cleanup + FileUtils.rm_rf(root) + end +end diff --git a/test/dev/clone/repo_spec_test.rb b/test/dev/clone/repo_spec_test.rb new file mode 100644 index 0000000..00a71d9 --- /dev/null +++ b/test/dev/clone/repo_spec_test.rb @@ -0,0 +1,51 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/clone" + +transform!(RSpock::AST::Transformation) +class Dev::Clone::RepoSpecTest < Minitest::Test + test "parses '#{arg}' as #{expected_org}/#{expected_name}" do + When "we parse the clone target" + spec = Dev::Clone::RepoSpec.parse(arg) + + Then "org and name land as expected" + spec.org == expected_org + spec.name == expected_name + + Where + arg | expected_org | expected_name + "dev" | "d3mlabs" | "dev" + "acme/widget" | "acme" | "widget" + "My.Repo-2" | "d3mlabs" | "My.Repo-2" + "JPDuchesne/x" | "JPDuchesne" | "x" + end + + test "renders the gh clone target and the canonical relative path" do + Given "a parsed spec" + spec = Dev::Clone::RepoSpec.parse("acme/widget") + + Expect "the gh target and the host/org/repo layout" + spec.full_name == "acme/widget" + spec.relative_path == Pathname("github.com/acme/widget") + end + + test "rejects '#{arg}' (#{reason})" do + When "we parse the malformed target" + Dev::Clone::RepoSpec.parse(arg) + + Then + raises Dev::Clone::RepoSpec::MalformedRepoError + + Where + arg | reason + "" | "empty" + "/" | "only a separator" + "a/b/c" | "too many segments" + "a/" | "trailing separator" + "/b" | "leading separator" + "a b" | "whitespace in a segment" + "org/a b" | "whitespace in the repo segment" + end +end diff --git a/test/dev/global_dispatch_test.rb b/test/dev/global_dispatch_test.rb index f760ec9..8af6207 100644 --- a/test/dev/global_dispatch_test.rb +++ b/test/dev/global_dispatch_test.rb @@ -19,6 +19,21 @@ def run(args) end end unless defined?(RecordingCredAccessor) +# A clone accessor stand-in recording its argv, so dispatch is tested without +# gh or shell RC writes. Subclasses the real accessor to satisfy the +# dispatcher's typed constructor. +class RecordingCloneAccessor < Dev::Clone::Accessor + attr_reader :last_args + + def initialize + super(root: Dir.tmpdir) + end + + def run(args, out: $stdout, err: $stderr) + @last_args = args + end +end unless defined?(RecordingCloneAccessor) + transform!(RSpock::AST::Transformation) class Dev::GlobalDispatchTest < Minitest::Test test "#{name} is a global command: #{expected}" do @@ -31,6 +46,7 @@ class Dev::GlobalDispatchTest < Minitest::Test Where name | expected "cd" | true + "clone" | true "plan" | true "cred" | true "learnings" | true @@ -39,6 +55,44 @@ class Dev::GlobalDispatchTest < Minitest::Test "update-deps" | false end + test "dev clone dispatches globally without a dev.yml lookup" do + Given "a recording clone accessor and a cwd with no dev.yml" + clone = RecordingCloneAccessor.new + dispatch = Dev::GlobalDispatch.new(clone_accessor: clone, cred_accessor: RecordingCredAccessor.new) + cwd = Dir.mktmpdir("dispatch-cwd-") + + When "we dispatch dev clone" + Dir.chdir(cwd) { dispatch.run(["clone", "--path", "d3mlabs/dev"]) } + + Then "the accessor received the subcommand argv" + clone.last_args == ["--path", "d3mlabs/dev"] + + Cleanup + FileUtils.rm_rf(cwd) + end + + test "dev clone against an existing checkout prints a clean error and exits non-zero" do + Given "the canonical destination already on disk" + root = Dir.mktmpdir("dispatch-clone-") + FileUtils.mkdir_p(File.join(root, "github.com", "d3mlabs", "dev")) + clone_accessor = Dev::Clone::Accessor.new(root: root, hook_installer: quiet_hook_installer) + dispatch = Dev::GlobalDispatch.new(clone_accessor: clone_accessor, cred_accessor: RecordingCredAccessor.new) + old_stderr = $stderr + $stderr = StringIO.new + Kernel.expects(:exit).with(1).once + + When "we dispatch the duplicate clone" + dispatch.run(["clone", "dev"]) + + Then "the error names the existing path and hints at dev cd" + $stderr.string.include?("already exists") + $stderr.string.include?("dev cd dev") + + Cleanup + $stderr = old_stderr + FileUtils.rm_rf(root) + end + test "dev learnings status dispatches globally without a dev.yml lookup" do Given "a knowledge repo via ENV and tmpdir-scoped XDG homes" dir = Dir.mktmpdir("dispatch-learnings-") From f305ac93b9655e9bf921284d233f5dfb799f47fb Mon Sep 17 00:00:00 2001 From: "d3mlabs-ai-flow[bot]" <305891656+d3mlabs-ai-flow[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:05:19 -0400 Subject: [PATCH 2/2] ai-flow /build: codecov coverage missing Co-authored-by: JPDuchesne <2636122+JPDuchesne@users.noreply.github.com> --- test/dev/clone/gh_cloner_test.rb | 26 ++++++++++++++++++++++++++ test/dev/runner_test.rb | 25 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/test/dev/clone/gh_cloner_test.rb b/test/dev/clone/gh_cloner_test.rb index c21e8b8..0ed6b3f 100644 --- a/test/dev/clone/gh_cloner_test.rb +++ b/test/dev/clone/gh_cloner_test.rb @@ -43,6 +43,32 @@ class Dev::Clone::GhClonerTest < Minitest::Test FileUtils.rm_rf(root) end + test "the real executor runs argv with the child's stdout redirected to stderr" do + Given "a real file standing in for stderr" + root = Dir.mktmpdir("gh-cloner-") + stderr_path = File.join(root, "stderr.txt") + original_stderr = $stderr + + When "we run a command that writes to stdout" + succeeded = File.open(stderr_path, "w") do |stand_in| + $stderr = stand_in + Dev::Clone::GhCloner::Executor.new.system("echo", "cloned") + end + + Then "it reports success and the child's stdout landed on stderr" + succeeded == true + File.read(stderr_path) == "cloned\n" + + Cleanup + $stderr = original_stderr + FileUtils.rm_rf(root) + end + + test "the real executor reports a failing command as false" do + Expect "a non-zero exit maps to false" + Dev::Clone::GhCloner::Executor.new.system("false") == false + end + test "a failing gh clone raises CloneFailedError" do Given "an executor whose gh invocation fails" root = Dir.mktmpdir("gh-cloner-") diff --git a/test/dev/runner_test.rb b/test/dev/runner_test.rb index 6f544f7..b7a00c2 100644 --- a/test/dev/runner_test.rb +++ b/test/dev/runner_test.rb @@ -233,6 +233,31 @@ class RunnerTest < Minitest::Test out.string.include?("Jump to a checkout") end + test "usage includes the clone builtin" do + Given "a Runner with no project commands" + runner = build_runner(commands: {}) + out = StringIO.new + + When "we print usage" + runner.run([], ui: fake_ui, out: out) + + Then "clone is listed" + out.string.include?("clone") + out.string.include?("Clone a GitHub repo") + end + + test "the clone builtin dispatches argv to the clone accessor" do + Given "a Runner and an expectation on the clone accessor" + runner = build_runner(commands: {}) + Dev::Clone::Accessor.any_instance.expects(:run).with(["acme/widget"]).once + + When "we run dev clone" + runner.run(["clone", "acme/widget"], ui: fake_ui) + + Then "the expectation on the accessor holds" + true + end + test "up ensures the dev cd shell hook (idempotently)" do Given "a Runner with no project up command and a hook installer expectation" runner = build_runner(commands: {})