From 1f0cd1149faff5829196134bdf68592ff84e6647 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 12:09:11 -0400 Subject: [PATCH] Extract the clone default org into the default_org setting dev is public and hardcodes no org content, so RepoSpec's hardcoded d3mlabs falls to a new nilable default_org settings key (ENV DEV_DEFAULT_ORG over the config file). A bare `dev clone ` with no configured org raises a typed error naming both remediations. Co-authored-by: Cursor --- README.md | 4 ++- lib/dev/clone/accessor.rb | 16 +++++++---- lib/dev/clone/repo_spec.rb | 25 ++++++++++++---- lib/dev/settings.rb | 21 ++++++++++++-- src/dev/builtins/clone_command.rb | 3 +- test/dev/clone/accessor_test.rb | 37 +++++++++++++++++++++--- test/dev/clone/repo_spec_test.rb | 26 +++++++++++++++-- test/dev/global_dispatch_test.rb | 9 ++++-- test/dev/settings_test.rb | 47 +++++++++++++++++++++++++++++++ 9 files changed, 163 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e44415e..34bc3e5 100644 --- a/README.md +++ b/README.md @@ -128,10 +128,12 @@ Only git repos count as candidates (directories with a `.git` entry — a `.git` `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 myrepo # bare name expands under the default_org setting → ~/src/github.com//myrepo dev clone acme/widget # explicit org ``` +A bare `` needs the `default_org` key in `~/.config/dev/config.yml` (or `DEV_DEFAULT_ORG`); without it, dev asks for an explicit `/` — dev is public and hardcodes no 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. diff --git a/lib/dev/clone/accessor.rb b/lib/dev/clone/accessor.rb index f72f43a..79bc98a 100644 --- a/lib/dev/clone/accessor.rb +++ b/lib/dev/clone/accessor.rb @@ -4,6 +4,7 @@ require "dev/clone/repo_spec" require "dev/clone/gh_cloner" require "dev/cd/hook_installer" +require "dev/settings" module Dev module Clone @@ -13,9 +14,10 @@ module Clone # provisioning stays a deliberate second step, where credential prompts # are expected. # - # The human command is `dev clone [/]` (org defaults to - # d3mlabs), handled by the installed shell wrapper (the same one that - # powers `dev cd`); the wrapper calls the hidden plumbing mode: + # The human command is `dev clone [/]` (bare names expand + # under the `default_org` setting), handled by the installed shell + # wrapper (the same one that powers `dev cd`); the wrapper calls the + # hidden plumbing mode: # # - `--path [/]`: clone, then print exactly the destination's # absolute path on stdout (the wrapper `builtin cd`s into it) @@ -35,11 +37,14 @@ 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] + # @param settings [Dev::Settings] source of the default_org key def initialize(root: ENV["DEV_CD_ROOT"] || (Pathname(Dir.home) / "src"), - cloner: GhCloner.new, hook_installer: Dev::Cd::HookInstaller.new) + cloner: GhCloner.new, hook_installer: Dev::Cd::HookInstaller.new, + settings: Dev::Settings.new) @root = Pathname(root).expand_path @cloner = cloner @hook_installer = hook_installer + @settings = settings end # Dispatch a `dev clone …` invocation. @@ -50,6 +55,7 @@ def initialize(root: ENV["DEV_CD_ROOT"] || (Pathname(Dir.home) / "src"), # @return [void] # @raise [UsageError] unless exactly one clone target is given # @raise [RepoSpec::MalformedRepoError] when the target isn't "" or "/" + # @raise [RepoSpec::MissingDefaultOrgError] on a bare "" with no default_org setting # @raise [DestinationExistsError] when the canonical path already exists # @raise [GhCloner::CloneFailedError] when the clone itself fails def run(args, out: $stdout, err: $stderr) @@ -57,7 +63,7 @@ def run(args, out: $stdout, err: $stderr) query = plumbing ? args.drop(1) : args raise UsageError, "usage: dev clone [/]" unless query.size == 1 - spec = RepoSpec.parse(query.fetch(0)) + spec = RepoSpec.parse(query.fetch(0), default_org: @settings.default_org) destination = @root / spec.relative_path if destination.exist? raise DestinationExistsError, "#{destination} already exists — jump there with `dev cd #{spec.name}`" diff --git a/lib/dev/clone/repo_spec.rb b/lib/dev/clone/repo_spec.rb index 1950509..f68f6e8 100644 --- a/lib/dev/clone/repo_spec.rb +++ b/lib/dev/clone/repo_spec.rb @@ -6,9 +6,11 @@ module Dev module Clone # The parsed target of a `dev clone` invocation. # - # Accepts "" (org defaults to d3mlabs) or "/". 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. + # Accepts "/", or a bare "" when the caller supplies a + # default org (the `default_org` setting — dev is public and hardcodes + # no org). 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 @@ -17,7 +19,9 @@ class RepoSpec # The argument is not a "" or "/" clone target. class MalformedRepoError < RuntimeError; end - DEFAULT_ORG = "d3mlabs" + # A bare "" target with no default org configured to expand it. + class MissingDefaultOrgError < RuntimeError; end + HOST = "github.com" # GitHub owner/repo name characters: word chars, dots, hyphens. @@ -30,10 +34,13 @@ class << self # Parse a clone target argument into a spec. # # @param arg [String] "" or "/" + # @param default_org [String, nil] org a bare "" expands under + # (the `default_org` setting); nil means bare targets are an error # @return [Dev::Clone::RepoSpec] # @raise [MalformedRepoError] when the argument is not one or two # valid path segments - def parse(arg) + # @raise [MissingDefaultOrgError] on a bare "" with no default org + def parse(arg, default_org: nil) # -1 keeps trailing empty segments, so "repo/" fails validation # instead of silently collapsing to "repo". segments = arg.split("/", -1) @@ -41,7 +48,13 @@ def parse(arg) raise MalformedRepoError, "expected or /, got '#{arg}'" end - org, name = segments.size == 2 ? segments : [DEFAULT_ORG, segments.fetch(0)] + if segments.size == 1 && default_org.nil? + raise MissingDefaultOrgError, + "no default org configured — use /, or set " \ + "`default_org: ` in ~/.config/dev/config.yml (or DEV_DEFAULT_ORG)" + end + + org, name = segments.size == 2 ? segments : [default_org, segments.fetch(0)] new(org:, name:) end end diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index 343d7e8..88d21a2 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -9,13 +9,15 @@ module Dev # # plans_repo: d3mlabs/plans # knowledge_repo: d3mlabs/knowledge + # default_org: d3mlabs # # `plans_repo` is the org-wide plans repo that `dev plan new --org` / # `dev plan link --org` target. `knowledge_repo` is the org knowledge repo # dev keeps a machine-local cache of; leaving it unset simply means no org - # learnings sync (dev is public and hardcodes no org content). ENV - # overrides: DEV_PLANS_REPO and DEV_KNOWLEDGE_REPO (matching the - # credentials ENV-first convention). + # learnings sync (dev is public and hardcodes no org content), and no + # `default_org` means `dev clone` needs explicit / targets. + # ENV overrides: DEV_PLANS_REPO, DEV_KNOWLEDGE_REPO and DEV_DEFAULT_ORG + # (matching the credentials ENV-first convention). class Settings class MissingSettingError < RuntimeError; end @@ -55,6 +57,19 @@ def knowledge_repo (value && !value.empty?) ? value : nil end + # The GitHub org a bare `dev clone ` expands under. Unset is a + # supported state: bare targets then require an explicit / — + # dev is public and hardcodes no org. + # + # @return [String, nil] the org name, or nil + def default_org + from_env = ENV["DEV_DEFAULT_ORG"] + return from_env if from_env && !from_env.empty? + + value = load_config["default_org"] + (value && !value.empty?) ? value : nil + end + private # @return [String] diff --git a/src/dev/builtins/clone_command.rb b/src/dev/builtins/clone_command.rb index be6f2bc..a166c88 100644 --- a/src/dev/builtins/clone_command.rb +++ b/src/dev/builtins/clone_command.rb @@ -14,7 +14,8 @@ class CloneCommand < BuiltinCommand # Shared with the global usage listing (GlobalDispatch), which reads # descriptions without instantiating the builtin. - DESC = "Clone a GitHub repo (via gh auth) into $DEV_CD_ROOT (default ~/src), org defaults to d3mlabs" + DESC = "Clone a GitHub repo (via gh auth) into $DEV_CD_ROOT (default ~/src); " \ + "bare names expand under the default_org setting" sig { params(accessor: Dev::Clone::Accessor).void } def initialize(accessor: Dev::Clone::Accessor.new) diff --git a/test/dev/clone/accessor_test.rb b/test/dev/clone/accessor_test.rb index 6028d39..8bf27b6 100644 --- a/test/dev/clone/accessor_test.rb +++ b/test/dev/clone/accessor_test.rb @@ -62,8 +62,8 @@ class Dev::Clone::AccessorTest < Minitest::Test FileUtils.rm_rf(root) end - test "a bare repo name defaults the org to d3mlabs" do - Given "an empty checkout root" + test "a bare repo name expands under the configured default org" do + Given "an empty checkout root and default_org: d3mlabs in settings" root = Dir.mktmpdir("clone-accessor-") cloner = FakeGhCloner.new accessor = build_accessor(root, cloner: cloner) @@ -78,6 +78,27 @@ class Dev::Clone::AccessorTest < Minitest::Test FileUtils.rm_rf(root) end + test "a bare repo name with no default org surfaces RepoSpec's typed error" do + Given "settings without a default_org key (and no ENV override in play)" + root = Dir.mktmpdir("clone-accessor-") + cloner = FakeGhCloner.new + accessor = build_accessor(root, cloner: cloner, default_org: nil) + saved_env = ENV.delete("DEV_DEFAULT_ORG") + + When "we clone by leaf name" + error = assert_raises(Dev::Clone::RepoSpec::MissingDefaultOrgError) do + accessor.run(["--path", "dev"], out: StringIO.new, err: StringIO.new) + end + + Then "the remediation names the settings key and no clone ran" + assert_includes error.message, "default_org" + cloner.calls == [] + + Cleanup + ENV["DEV_DEFAULT_ORG"] = saved_env if saved_env + 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-") @@ -185,7 +206,15 @@ class Dev::Clone::AccessorTest < Minitest::Test private - def build_accessor(root, cloner: FakeGhCloner.new, hook_installer: FakeCloneHookInstaller.new) - Dev::Clone::Accessor.new(root: root, cloner: cloner, hook_installer: hook_installer) + # Real Settings over a throwaway config file (no filesystem mocks): the + # default_org tests exercise the same read path production uses. + def build_accessor(root, cloner: FakeGhCloner.new, hook_installer: FakeCloneHookInstaller.new, + default_org: "d3mlabs") + config_path = File.join(Dir.mktmpdir("clone-accessor-settings-"), "config.yml") + File.write(config_path, default_org ? "default_org: #{default_org}\n" : "") + Dev::Clone::Accessor.new( + root: root, cloner: cloner, hook_installer: hook_installer, + settings: Dev::Settings.new(config_path: config_path), + ) end end diff --git a/test/dev/clone/repo_spec_test.rb b/test/dev/clone/repo_spec_test.rb index 00a71d9..c3230e8 100644 --- a/test/dev/clone/repo_spec_test.rb +++ b/test/dev/clone/repo_spec_test.rb @@ -6,9 +6,9 @@ transform!(RSpock::AST::Transformation) class Dev::Clone::RepoSpecTest < Minitest::Test - test "parses '#{arg}' as #{expected_org}/#{expected_name}" do + test "parses '#{arg}' as #{expected_org}/#{expected_name} with a default org configured" do When "we parse the clone target" - spec = Dev::Clone::RepoSpec.parse(arg) + spec = Dev::Clone::RepoSpec.parse(arg, default_org: "d3mlabs") Then "org and name land as expected" spec.org == expected_org @@ -22,9 +22,29 @@ class Dev::Clone::RepoSpecTest < Minitest::Test "JPDuchesne/x" | "JPDuchesne" | "x" end + test "an explicit / needs no default org" do + When "we parse a fully-qualified target with no default org" + spec = Dev::Clone::RepoSpec.parse("acme/widget") + + Then + spec.org == "acme" + spec.name == "widget" + end + + test "a bare with no default org raises MissingDefaultOrgError naming the settings key" do + When "we parse a bare target with no default org" + error = assert_raises(Dev::Clone::RepoSpec::MissingDefaultOrgError) do + Dev::Clone::RepoSpec.parse("dev") + end + + Then "the remediation names both the key and the ENV override" + assert_includes error.message, "default_org" + assert_includes error.message, "DEV_DEFAULT_ORG" + end + test "renders the gh clone target and the canonical relative path" do Given "a parsed spec" - spec = Dev::Clone::RepoSpec.parse("acme/widget") + spec = Dev::Clone::RepoSpec.parse("acme/widget", default_org: "d3mlabs") Expect "the gh target and the host/org/repo layout" spec.full_name == "acme/widget" diff --git a/test/dev/global_dispatch_test.rb b/test/dev/global_dispatch_test.rb index 9063091..a0b4636 100644 --- a/test/dev/global_dispatch_test.rb +++ b/test/dev/global_dispatch_test.rb @@ -72,10 +72,15 @@ class Dev::GlobalDispatchTest < Minitest::Test end test "dev clone against an existing checkout prints a clean error and exits non-zero" do - Given "the canonical destination already on disk" + Given "the canonical destination already on disk, and a configured default org" 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) + config_path = File.join(root, "config.yml") + File.write(config_path, "default_org: d3mlabs\n") + clone_accessor = Dev::Clone::Accessor.new( + root: root, hook_installer: quiet_hook_installer, + settings: Dev::Settings.new(config_path: config_path), + ) dispatch = Dev::GlobalDispatch.new(clone_accessor: clone_accessor, cred_accessor: RecordingCredAccessor.new) old_stderr = $stderr $stderr = StringIO.new diff --git a/test/dev/settings_test.rb b/test/dev/settings_test.rb index 99dbbff..53f1e6c 100644 --- a/test/dev/settings_test.rb +++ b/test/dev/settings_test.rb @@ -99,4 +99,51 @@ class Dev::SettingsTest < Minitest::Test ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env FileUtils.rm_rf(dir) end + + test "default_org reads from the config file" do + Given "a config file declaring the default GitHub org" + dir = Dir.mktmpdir("dev-settings-test-") + path = File.join(dir, "config.yml") + File.write(path, "default_org: d3mlabs\n") + saved_env = ENV.delete("DEV_DEFAULT_ORG") + settings = Dev::Settings.new(config_path: path) + + Expect + settings.default_org == "d3mlabs" + + Cleanup + ENV["DEV_DEFAULT_ORG"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "DEV_DEFAULT_ORG overrides the config file" do + Given "a config file and an ENV override" + dir = Dir.mktmpdir("dev-settings-test-") + path = File.join(dir, "config.yml") + File.write(path, "default_org: d3mlabs\n") + saved_env = ENV["DEV_DEFAULT_ORG"] + ENV["DEV_DEFAULT_ORG"] = "acme" + settings = Dev::Settings.new(config_path: path) + + Expect + settings.default_org == "acme" + + Cleanup + saved_env ? ENV["DEV_DEFAULT_ORG"] = saved_env : ENV.delete("DEV_DEFAULT_ORG") + FileUtils.rm_rf(dir) + end + + test "an unset default_org is nil — explicit / targets are a supported state" do + Given "no config file" + dir = Dir.mktmpdir("dev-settings-test-") + saved_env = ENV.delete("DEV_DEFAULT_ORG") + settings = Dev::Settings.new(config_path: File.join(dir, "config.yml")) + + Expect + settings.default_org.nil? + + Cleanup + ENV["DEV_DEFAULT_ORG"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end end