Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,12 @@ Only git repos count as candidates (directories with a `.git` entry — a `.git`
`dev clone [<org>/]<repo>` 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/<org>/<repo>`, 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/<default_org>/myrepo
dev clone acme/widget # explicit org
```

A bare `<repo>` needs the `default_org` key in `~/.config/dev/config.yml` (or `DEV_DEFAULT_ORG`); without it, dev asks for an explicit `<org>/<repo>` — 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 <repo>` → `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.
Expand Down
16 changes: 11 additions & 5 deletions lib/dev/clone/accessor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,9 +14,10 @@ module Clone
# 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:
# The human command is `dev clone [<org>/]<repo>` (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 [<org>/]<repo>`: clone, then print exactly the destination's
# absolute path on stdout (the wrapper `builtin cd`s into it)
Expand All @@ -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.
Expand All @@ -50,14 +55,15 @@ 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 "<repo>" or "<org>/<repo>"
# @raise [RepoSpec::MissingDefaultOrgError] on a bare "<repo>" 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)
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))
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}`"
Expand Down
25 changes: 19 additions & 6 deletions lib/dev/clone/repo_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ 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.
# Accepts "<org>/<repo>", or a bare "<repo>" 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
Expand All @@ -17,7 +19,9 @@ class RepoSpec
# The argument is not a "<repo>" or "<org>/<repo>" clone target.
class MalformedRepoError < RuntimeError; end

DEFAULT_ORG = "d3mlabs"
# A bare "<repo>" 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.
Expand All @@ -30,18 +34,27 @@ class << self
# Parse a clone target argument into a spec.
#
# @param arg [String] "<repo>" or "<org>/<repo>"
# @param default_org [String, nil] org a bare "<repo>" 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 "<repo>" 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)
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)]
if segments.size == 1 && default_org.nil?
raise MissingDefaultOrgError,
"no default org configured — use <org>/<repo>, or set " \
"`default_org: <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
Expand Down
21 changes: 18 additions & 3 deletions lib/dev/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org>/<repo> 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

Expand Down Expand Up @@ -55,6 +57,19 @@ def knowledge_repo
(value && !value.empty?) ? value : nil
end

# The GitHub org a bare `dev clone <repo>` expands under. Unset is a
# supported state: bare targets then require an explicit <org>/<repo> —
# 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]
Expand Down
3 changes: 2 additions & 1 deletion src/dev/builtins/clone_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 33 additions & 4 deletions test/dev/clone/accessor_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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-")
Expand Down Expand Up @@ -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
26 changes: 23 additions & 3 deletions test/dev/clone/repo_spec_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,9 +22,29 @@ class Dev::Clone::RepoSpecTest < Minitest::Test
"JPDuchesne/x" | "JPDuchesne" | "x"
end

test "an explicit <org>/<repo> 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 <repo> 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"
Expand Down
9 changes: 7 additions & 2 deletions test/dev/global_dispatch_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions test/dev/settings_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org>/<repo> 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
Loading