space-cadet 9.1.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe74accf7bbff15d13d2de6eb5a9b6a99733ffed834023d78b49876c12e7eb8a
4
- data.tar.gz: f34df4aee5aa19b2a25a695e7aa97807d8cff94c12e7006935cef71951a0904b
3
+ metadata.gz: c191a11ce1e7a132b55a094cf765ea168bc58a143c4d6239db28354349127ce9
4
+ data.tar.gz: a64cf8802acd3667b51783eaf3677223654a1cdb07f853d3ee9c72ef94ce55a5
5
5
  SHA512:
6
- metadata.gz: 2a85cb897404242cf3e195ce6651c5f474a6765793264bde9e20a6ad23f11f24ce23c843cb08142f029bbf129c61146e73968a45d8be842ca277e0f89dc5e221
7
- data.tar.gz: 4fbea23cf9429c4664777cb05819d8d20e5fa149bf36824f91f1e367be09defb108e8c4ccf38675dca9ce460d918aa06426e6486fd0db0b68a0944d47e6263d8
6
+ metadata.gz: 0a752744e4deb343a3f59b8cc45b25b67769e26243299e9825256e186db05361a8b299723877d71bcc1599c4321509d3735b4564f4ce876703c85d8ccade8dc2
7
+ data.tar.gz: c88b462412b4a43316678886c5c19ca3dcdbbe5492cd9af31031a6fc0f19064c3dda41dbca437684460d41c3e81be5d02fc1bdfd4b156bcea005c2df22e35dc0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [9.2.0] - 2026-10-16
9
+
10
+ ### Added
11
+
12
+ - **`--trust-host-key` flag on `space repo add` and `space new`.** Appends
13
+ `-o StrictHostKeyChecking=accept-new` to the ssh command used for clones
14
+ (TOFU: first-contact host keys are accepted automatically; *changed* keys
15
+ still fail verification, so MITM protection is retained). Using the flag
16
+ prints a stderr warning before fetching begins. `space` never sets
17
+ `StrictHostKeyChecking=no`.
18
+
19
+ ### Changed
20
+
21
+ - **ssh clones never prompt.** Every network clone now runs the child with
22
+ `GIT_SSH_COMMAND` set to `<inherited GIT_SSH_COMMAND, or "ssh"> -o
23
+ BatchMode=yes` (options appended after an inherited value, assumed
24
+ ssh-compatible; git ignores the variable for https URLs), so host-key,
25
+ passphrase, and password prompts fail fast instead of hanging behind the
26
+ progress spinner. A failure containing `Host key verification failed`
27
+ surfaces guidance naming `--trust-host-key` and `known_hosts`; every other
28
+ failure surfaces the raw stderr as before.
29
+ - **Idempotent `space repo add` / `space new -r`.** Re-adding an
30
+ already-tracked repo is a no-op success (`Already present: …`, no clone,
31
+ no new `space.yaml` entry), and an untracked destination that already
32
+ contains a `.git` directory is adopted — mise-trusted and registered with
33
+ no fetch (`Registered existing checkout: …`) — so the corrected rerun
34
+ after a partially-failed batch succeeds instead of erroring on the
35
+ orphaned destination. A destination without `.git` is still refused
36
+ (not-a-git-repository error), path/name collisions with a *different*
37
+ tracked repo still error, and registration remains all-or-nothing: any
38
+ fetch failure exits non-zero and registers nothing.
39
+
8
40
  ## [9.1.0] - 2026-10-10
9
41
 
10
42
  ### Added
data/README.md CHANGED
@@ -16,244 +16,37 @@ evergreen checkouts spaces provision from).
16
16
  tool was absorbed into space-architect (up to 8.0.0) and is extracted back out
17
17
  here as of 9.0.0.
18
18
 
19
- ## What's a space? 🪐
20
-
21
- A space is just a regular directory with a tiny YAML identity file and room for
22
- everything a task needs:
23
-
24
- ```text
25
- ~/architect/spaces/20260531-name-of-space/
26
- space.yaml # identity: id, title, status, repos, notes, tags
27
- README.md
28
- repos/ # cloned (or copy-on-write'd) repositories
29
- notes/ # scratch, prompts, logs
30
- architecture/ # project files when the space runs an Architect project
31
- build/ # OCI contexts + scratch (build/oci/)
32
- tmp/ # workspace-local temp — use this instead of /tmp
33
- ```
34
-
35
- Run a command from anywhere inside a space and it just works — `space` walks up
36
- from `$PWD` until it finds the nearest `space.yaml`. No "current space" state
37
- to get out of sync; where you *are* is the space you mean. 🧭
38
-
39
19
  ## Installation 📦
40
20
 
41
- Add it to your `Gemfile`:
42
-
43
- ```ruby
44
- gem "space-cadet"
45
- ```
46
-
47
- ```bash
48
- bundle install
49
- ```
50
-
51
- Or grab it yourself:
52
-
53
- ```bash
54
- gem install space-cadet
55
- ```
56
-
57
- The gem is `space-cadet`; it installs one executable, `space`. 🎀
58
-
59
- ## Quick start 🎀
60
-
61
- ```sh
62
- space init # create XDG config + state files
63
- space new "Name of Space" # blast off a new space 🚀
64
- space new "Name of Space" -r org/repo -r org/lib # …with repos cloned in (repeat -r)
65
- space list # see all your spaces
66
- space show # show the space you're standing in
67
-
68
- # Containers (run from inside a space) — a portable, reproducible-by-SHA image of the space
69
- space pack # render build/oci/ (Dockerfile + entrypoint)
70
- space build # pack + build & tag <space-id>:<git-sha>
71
- space run # run it — login shell, auth from your env
72
- ```
73
-
74
- ## Commands 🌌
75
-
76
- ```sh
77
- space init
78
- space new "Name of Space"
79
- space new "Name of Space" -r org/repo -r example-tools/alpha -r example-tools/beta
80
- space list # alias: space ls
81
- space show 20260531-name-of-space
82
- space path 20260531-name-of-space
83
- space current # based on $PWD
84
- space show # based on $PWD
85
- space status # report: metadata (+ project status when present)
86
- space status 20260531-name-of-space # report another space
87
- space status done # set: active | paused | done | archived
88
- space status 20260531-name-of-space done
89
- space config set default_provider github.com
90
- space config set default_organization example-org
91
- space repo add example-app # github.com/example-org/example-app
92
- space repo add example-tools/alpha example-tools/beta
93
- space repo add gitlab.com/example-org/api
94
- space repo resolve example-app example-tools/async
95
- space repo ls # alias: space repos ls
96
- space use 20260531-name-of-space # records recent state, prints the path
97
- space ls --color=always # auto | always | never (--colors also accepted)
98
- ```
99
-
100
- `space status` is **report-or-set**: with no status keyword (bare, or with just
101
- a space id) it *reports* the space — its metadata plus a compact project-status
102
- block when the space declares one, quietly omitted otherwise; pass a status
103
- keyword (`active`, `paused`, `done`, `archived`) to *set* it instead. 🔁
104
-
105
- Repos are passed with a repeatable `-r` flag (`-r org/repo -r org/lib`); the
106
- comma form (`-r a,b`) works too. Space ids are date-prefixed
107
- (`20260531-name-of-space`) so they sort naturally, and duplicate names on the
108
- same day get a counter (`…-name-of-space-2`). 📅
109
-
110
- Full command reference: [docs/reference.md](docs/reference.md).
111
-
112
- ## Containerize a space: `pack` · `build` · `run` 📦
113
-
114
- A space is self-describing enough to become a container. `space pack` renders a
115
- portable OCI build context from the space; `space build` packs and builds it into
116
- a **reproducible-by-SHA** image; `space run` runs that image with your auth
117
- injected at runtime and stateful paths bind-mounted back to the host. 🐳
118
-
119
21
  ```sh
120
- space pack # render build/oci/ (Dockerfile + entrypoint + ignore file)
121
- space pack -o /tmp/ctx # …to a different output directory
122
- space build # pack, then build & tag <space-id>:<git-sha> and :latest
123
- space run # run <space-id>:latest — login shell, auth from your env
124
- space run architect status # …or run a one-off command instead of the login shell
125
- space run --tty # force an interactive TTY (default: auto-detect)
126
- ```
127
-
128
- **What lands in the image.** The context copies the whole space tree (filtered by
129
- a generated `Dockerfile.dockerignore`) onto a `ruby:4.0.5` base with `git`, the
130
- Claude Code CLI, and the `space-cadet` gem — installed from the in-space
131
- `repos/space-cadet` checkout when present (a pinned build), else from
132
- RubyGems. Secrets never enter the layers: `.env`, `*.key`, `*.pem`, ssh keys,
133
- `build/`, and `tmp/` are all excluded by the generated ignore file. 🔒
134
-
135
- **Reproducible by SHA.** `space build` tags the image `<space-id>:<sha>`, where
136
- `<sha>` is the space repo's 12-char `HEAD` (suffixed `-dirty` when the tree has
137
- uncommitted changes), plus a moving `:latest`. Same commit → same tag → same
138
- image. It drives the `container` CLI, but the output is an ordinary OCI/Docker
139
- build context, so `docker build -f build/oci/Dockerfile .` (from the space root)
140
- works just as well.
141
-
142
- **Auth stays out of the layers.** `space run` injects only the auth environment
143
- variables that are actually set — `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`,
144
- `ANTHROPIC_BASE_URL` — with `-e` at run time, so credentials live in your shell,
145
- never in the image. 🗝️
146
-
147
- **Forwarding payload credentials.** Beyond the always-on auth trio, a space can declare
148
- `run.env:` in `space.yaml` — a list of host env var names forwarded into the container at
149
- run time. You can also pass `space run --env VAR` (repeatable) for ad hoc additions. All
150
- forwarding is bare `-e VAR` passthrough: values never appear in argv, `ps`, or the image.
151
- A requested-but-unset var warns on stderr instead of silently failing inside the guest.
152
-
153
- ```yaml
154
- run:
155
- env: # run-time: host var names, forwarded as bare -e VAR
156
- - FIREWORKS_API_KEY
157
- - OPENAI_API_KEY
158
- ```
159
-
160
- **Declaring provisioning & persistence** — two optional keys in `space.yaml`:
161
-
162
- ```yaml
163
- pack:
164
- provision: # build-time: relative scripts, baked in by `space build`
165
- - scripts/setup-toolchain.sh
166
- persist: # run-time: absolute guest paths, bind-mounted per run
167
- - /root/.claude
168
- - /root/.local/state
169
- ```
170
-
171
- `provision` scripts must live under the space root and be executable. Each script is
172
- copied into the image individually (`COPY <script> /space/<script>`) and then invoked
173
- (`RUN /space/<script>`) **before** the full space tree lands and **before** the gem is
174
- installed. This ordering is the cache-hygiene guarantee: editing any other space file
175
- leaves the provision and gem-install layers cached, so a rebuild completes in seconds
176
- instead of minutes. Scripts must therefore be self-contained — they run with only the
177
- base system layers and any earlier provision scripts' outputs; they cannot read other
178
- space files or call `space`. Scripts must have the executable bit set; COPY preserves
179
- the mode from the build context.
180
-
181
- `persist` paths must be absolute; `space run` bind-mounts each one from
182
- `<space>/.state<path>` on the host (created on first run) so a container's mutable state
183
- survives across runs. Both are validated at pack time.
184
-
185
- ## Fish shell integration 🐟
186
-
187
- Shells can't let a child process change *their* working directory, so `space`
188
- ships a small fish wrapper function plus completions (commands, subcommands,
189
- spaces, statuses, config keys, repo refs). Install into fish's autoloaded
190
- directories:
191
-
192
- ```fish
193
- space shell fish install
194
- exec fish
195
- ```
196
-
197
- After restarting fish (or `exec fish`), `space new "…"` and
198
- `space use <id>` will `cd` into the selected space once the command succeeds;
199
- every other command keeps normal CLI behavior. 🚪 The functions and completions
200
- are written under `~/.config/fish/`, so there's no need to edit `config.fish`.
201
- For one-off testing without installing:
202
-
203
- ```fish
204
- space shell init fish | source
205
- ```
206
-
207
- ## Configuration ⚙️
208
-
209
- Config lives at `~/.config/space-cadet/config.yml` (XDG-aware) and defaults to:
210
-
211
- ```yaml
212
- version: 1
213
- base_dir: ~/architect # spaces_dir + src_dir hang off this by default
214
- default_provider: github.com
215
- default_organization:
216
- git_clone_protocol: ssh # ssh | https
22
+ gem install space-cadet
217
23
  ```
218
24
 
219
- Derived defaults: `spaces_dir` → `<base_dir>/spaces`, `src_dir` (evergreen
220
- checkout root) → `<base_dir>/src`. Override either explicitly. View values with
221
- `space config show`; set one with `space config set KEY VALUE`. Editable keys:
222
- `base_dir`, `spaces_dir`, `src_dir`, `default_provider`, `default_organization`,
223
- `git_clone_protocol`.
25
+ or add `gem "space-cadet"` to your `Gemfile` and `bundle install`. The gem
26
+ installs one executable, `space`. 🎀
224
27
 
225
- ## Repos: evergreen, copy-on-write, concurrent ⚡
226
-
227
- Repos are added to the current space under `repos/` and tracked in `space.yaml`.
228
- When an up-to-date evergreen checkout exists at
229
- `<src_dir>/<host>/<owner>/<name>` (e.g.
230
- `~/architect/src/github.com/example-org/example-app` — tended by
231
- [jetpks/repo-tender](https://github.com/jetpks/repo-tender)), `space` copies it
232
- into the space instead of cloning over the network — a copy-on-write clone on
233
- APFS. ⚡ Set `src_dir` empty to always clone:
28
+ ## 60-second start 🚀
234
29
 
235
30
  ```sh
236
- space config set src_dir ""
31
+ space init # create XDG config + state files
32
+ space new "My First Space" # blast off a new space 🪐
33
+ cd ~/architect/spaces/*my-first-space
34
+ space status # report where you are
237
35
  ```
238
36
 
239
- Clone URLs default to SSH (`git@github.com:example-org/example-app.git`); switch
240
- with `space config set git_clone_protocol https`. Multiple repos passed to
241
- `space repo add` are fetched **concurrently**, up to five at a time, on fibers —
242
- no threads, all cooperative. 🧵 After each repo lands, `space` runs `mise trust`
243
- in it. Each space also gets a workspace-local `tmp/` — use it instead of `/tmp`.
244
-
245
- ## Embedding 📚
246
-
247
- ```ruby
248
- require "space_core" # config, state, XDG, terminal, git/mise clients,
249
- # the space store — the `space` CLI runs on this alone
250
- ```
37
+ Full learning journey: [your first space](docs/tutorials/first-space.md).
251
38
 
252
39
  ## Documentation 📖
253
40
 
254
- - **[Command Reference](docs/reference.md)** — every command, flag, and behavior
255
- - **[Releasing](docs/RELEASING.md)** — how a release lands
256
- - **[Changelog](CHANGELOG.md)** — release history
41
+ Organized by [Diátaxis](https://diataxis.fr/) — pick the quadrant that matches
42
+ what you're here for:
43
+
44
+ | I want to… | Go to |
45
+ |---|---|
46
+ | **learn** the tool step by step | [Tutorials](docs/tutorials/) |
47
+ | **get something done** (a recipe) | [How-to guides](docs/how-to/) |
48
+ | **look up** a command, flag, or path | [Reference](docs/reference/) |
49
+ | **understand** why it works this way | [Explanation](docs/explanation/) |
257
50
 
258
51
  ## Development 🛠️
259
52
 
@@ -5,6 +5,30 @@ require "dry/monads"
5
5
  module Space::Core::CLI
6
6
  module Helpers
7
7
  include Dry::Monads[:result]
8
+
9
+ # Printed before any fetch when --trust-host-key is set. The operator
10
+ # should see the TOFU semantics at flag time — deterministic and
11
+ # locale-independent — rather than via ssh's own "Permanently added …"
12
+ # stderr line, which would need locale-fragile scraping to relay.
13
+ TRUST_HOST_KEY_WARNING =
14
+ "Warning: trusting unknown host keys on first contact (--trust-host-key); changed keys still fail verification."
15
+
16
+ def warn_trust_host_key
17
+ terminal.error TRUST_HOST_KEY_WARNING
18
+ end
19
+
20
+ # One success line per repo-addition outcome. `Added <full_name>` keeps
21
+ # its exact historical shape (existing tests assert it); the other two
22
+ # make idempotent adds visible — what was fetched vs skipped vs adopted.
23
+ def repo_addition_line(result)
24
+ full_name = result.fetch(:repo).fetch("full_name")
25
+ case result.fetch(:outcome)
26
+ when :present then "Already present: #{full_name}"
27
+ when :adopted then "Registered existing checkout: #{full_name}"
28
+ else "Added #{full_name}"
29
+ end
30
+ end
31
+
8
32
  def project_config
9
33
  @project_config ||= Space::Core::Config.load
10
34
  end
@@ -67,9 +91,22 @@ class RepoProgress
67
91
  @statuses = {}
68
92
  end
69
93
 
94
+ # No fetch happens for an :adopted addition (its checkout is already on
95
+ # disk) — label it adopting, never cloning/copying. :present additions
96
+ # never start at all; they are marked done up front via #skip.
70
97
  def start(addition)
71
- source = addition[:src_source]
72
- @statuses[addition.fetch(:reference).full_name] = source&.directory? ? :copying : :cloning
98
+ repo = addition.fetch(:reference).full_name
99
+ case addition.fetch(:outcome, :added)
100
+ when :adopted
101
+ @statuses[repo] = :adopting
102
+ else
103
+ source = addition[:src_source]
104
+ @statuses[repo] = source&.directory? ? :copying : :cloning
105
+ end
106
+ end
107
+
108
+ def skip(addition)
109
+ @statuses[addition.fetch(:reference).full_name] = :done
73
110
  end
74
111
 
75
112
  def trust(addition)
@@ -89,14 +126,17 @@ class RepoProgress
89
126
  failed = @statuses.count { |_repo, status| status == :failed }
90
127
  copying = @statuses.select { |_repo, status| status == :copying }.keys
91
128
  cloning = @statuses.select { |_repo, status| status == :cloning }.keys
129
+ adopting = @statuses.select { |_repo, status| status == :adopting }.keys
92
130
  trusting = @statuses.select { |_repo, status| status == :trusting }.keys
93
131
 
94
132
  if @total == 1
95
133
  copying_repo = copying.first
96
134
  cloning_repo = cloning.first
135
+ adopting_repo = adopting.first
97
136
  trusting_repo = trusting.first
98
137
  return "Copying #{copying_repo}" if copying_repo
99
138
  return "Cloning #{cloning_repo}" if cloning_repo
139
+ return "Adopting #{adopting_repo}" if adopting_repo
100
140
  return "Trusting #{trusting_repo}" if trusting_repo
101
141
  return "Fetch failed" if failed.positive?
102
142
 
@@ -105,6 +145,7 @@ class RepoProgress
105
145
  active = []
106
146
  active << "copying #{copying.join(', ')}" unless copying.empty?
107
147
  active << "cloning #{cloning.join(', ')}" unless cloning.empty?
148
+ active << "adopting #{adopting.join(', ')}" unless adopting.empty?
108
149
  active << "trusting #{trusting.join(', ')}" unless trusting.empty?
109
150
  suffix = active.empty? ? nil : ": #{active.join('; ')}"
110
151
  failed_text = failed.positive? ? ", #{failed} failed" : ""
@@ -6,9 +6,11 @@ class New < BaseCommand
6
6
  argument :title, required: true, desc: "Space title"
7
7
  option :repo, type: :array, aliases: ["-r"], desc: "Repo ref to clone (repeatable: pass -r once per repo)"
8
8
  option :git, type: :boolean, default: true, desc: "Initialize the space as a Git repository (use --no-git to skip)"
9
+ option :trust_host_key, type: :boolean, default: false,
10
+ desc: "Accept unknown ssh host keys on first contact (changed keys still fail)"
9
11
  example "\"My Space\" -r org/repo -r example-tools/alpha # clone two repos into the space"
10
12
 
11
- def call(title:, repo: [], git: true, **opts)
13
+ def call(title:, repo: [], git: true, trust_host_key: false, **opts)
12
14
  setup_terminal(**opts.slice(:color, :colors))
13
15
  result = store.create(title, git: git).bind do |space|
14
16
  terminal.success "Created #{space.id}"
@@ -18,12 +20,15 @@ class New < BaseCommand
18
20
 
19
21
  next Success(space) if repo_specs.empty?
20
22
 
23
+ warn_trust_host_key if trust_host_key
21
24
  progress = RepoProgress.new(repo_specs.length)
22
25
  terminal.with_spinner(-> { progress.message }) do
23
- store.add_repos_to(space, repo_specs, reporter: progress)
26
+ store.add_repos_to(space, repo_specs,
27
+ scm: Space::Core::SCM::Git.new(trust_host_key: trust_host_key),
28
+ reporter: progress)
24
29
  end.fmap do |results|
25
30
  results.each do |r|
26
- terminal.success "Added #{r.fetch(:repo).fetch('full_name')}"
31
+ terminal.success repo_addition_line(r)
27
32
  terminal.say terminal.path(r.fetch(:path))
28
33
  end
29
34
  space
@@ -5,8 +5,10 @@ module Repo
5
5
  class Add < BaseCommand
6
6
  desc "Add repos to the current space (copy-on-write from an evergreen checkout when available, else clone)"
7
7
  argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
8
+ option :trust_host_key, type: :boolean, default: false,
9
+ desc: "Accept unknown ssh host keys on first contact (changed keys still fail)"
8
10
 
9
- def call(repos: [], **opts)
11
+ def call(repos: [], trust_host_key: false, **opts)
10
12
  setup_terminal(**opts.slice(:color, :colors))
11
13
  handle_errors do
12
14
  specs = Array(repos).compact
@@ -16,13 +18,14 @@ module Repo
16
18
  next
17
19
  end
18
20
 
21
+ warn_trust_host_key if trust_host_key
19
22
  progress = RepoProgress.new(specs.length)
20
23
  add_result = terminal.with_spinner(-> { progress.message }) do
21
- store.add_repos(specs, reporter: progress)
24
+ store.add_repos(specs, scm: Space::Core::SCM::Git.new(trust_host_key: trust_host_key), reporter: progress)
22
25
  end
23
26
  render(add_result) do |results|
24
27
  results.each do |result|
25
- terminal.success "Added #{result.fetch(:repo).fetch('full_name')}"
28
+ terminal.success repo_addition_line(result)
26
29
  terminal.say terminal.path(result.fetch(:path))
27
30
  end
28
31
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -11,6 +11,17 @@ module Space::Core
11
11
  # Git CLI implementation of SCM::Client. All subprocess work is
12
12
  # delegated to Shell.run (which requires an ambient Async::Task).
13
13
  class Git < Client
14
+ # trust_host_key: append `-o StrictHostKeyChecking=accept-new` to the
15
+ # ssh command clones run under (TOFU: first-contact host keys are
16
+ # accepted automatically, changed keys still fail verification). The
17
+ # default (false) keeps strict host key checking. Host key
18
+ # verification is never disabled outright — the operator's "just
19
+ # disable it" urge is met with the strictly safer accept-new shape,
20
+ # opt-in only.
21
+ def initialize(trust_host_key: false)
22
+ @trust_host_key = trust_host_key
23
+ end
24
+
14
25
  # Resolve the bare remote's HEAD. First try the local
15
26
  # `origin/HEAD` symbolic ref; if missing/stale, do a one-shot
16
27
  # `git remote set-head origin -a` (network) to refresh it, then
@@ -107,10 +118,18 @@ module Space::Core
107
118
  Dry::Monads::Success(parsed)
108
119
  end
109
120
 
121
+ # Every clone runs ssh non-interactively. Open3.capture3 drains the
122
+ # child's stdout/stderr pipes, but ssh opens the controlling terminal
123
+ # (/dev/tty) directly for its host-key/passphrase/password prompts, so
124
+ # without BatchMode=yes a first-contact ssh clone hangs forever behind
125
+ # the CLI spinner. GIT_SSH_COMMAND makes every ssh prompt fail fast
126
+ # instead; git ignores it for https URLs, so it is set unconditionally
127
+ # — no URL-scheme conditional. An inherited GIT_SSH_COMMAND is honored
128
+ # with our options appended after it (assumed ssh-compatible).
110
129
  def clone(url, dest)
111
130
  parent = File.dirname(dest)
112
131
  FileUtils.mkdir_p(parent)
113
- result = Shell.run("git", "clone", url, dest)
132
+ result = Shell.run("git", "clone", url, dest, env: {"GIT_SSH_COMMAND" => git_ssh_command})
114
133
  if result.success?
115
134
  Dry::Monads::Success(dest)
116
135
  else
@@ -165,6 +184,15 @@ module Space::Core
165
184
 
166
185
  private
167
186
 
187
+ # Computed at clone time from the in-process ENV so callers may set
188
+ # GIT_SSH_COMMAND after this SCM was constructed. An unset or blank
189
+ # inherited value degrades to plain "ssh".
190
+ def git_ssh_command
191
+ base = ENV["GIT_SSH_COMMAND"].to_s.strip.empty? ? "ssh" : ENV["GIT_SSH_COMMAND"]
192
+ trust_args = @trust_host_key ? ["-o", "StrictHostKeyChecking=accept-new"] : []
193
+ [base, "-o", "BatchMode=yes", *trust_args].join(" ")
194
+ end
195
+
168
196
  # `git symbolic-ref --short refs/remotes/origin/HEAD` returns
169
197
  # `origin/<branch>` (the short form of `refs/remotes/origin/<branch>`);
170
198
  # callers want the bare branch name. Returns nil when origin/HEAD
@@ -122,16 +122,22 @@ module Space::Core
122
122
 
123
123
  def add_repos_to(space, specs, scm: SCM::Git.new, cloner: nil, mise_client: MiseClient.new, reporter: nil)
124
124
  additions = prepare_repo_additions(space, specs)
125
+ # :present additions (already tracked) do no work at all; mark them
126
+ # done for the reporter so progress counts every spec without ever
127
+ # claiming a fetch for one.
128
+ additions.each { |addition| reporter&.skip(addition) if addition.fetch(:outcome) == :present }
125
129
  first_error = nil
126
130
 
127
131
  Async do |task|
128
132
  semaphore = Async::Semaphore.new(MAX_CONCURRENT_CLONES, parent: task)
129
133
 
130
134
  clone_tasks = additions.map do |addition|
135
+ next if addition.fetch(:outcome) == :present
136
+
131
137
  semaphore.async(finished: false) do
132
138
  clone_addition(addition, scm:, cloner:, mise_client:, reporter:)
133
139
  end
134
- end
140
+ end.compact
135
141
 
136
142
  # Collect results without raising inside the reactor so the outer task
137
143
  # succeeds and async does not log "Task may have ended" for our errors.
@@ -145,8 +151,16 @@ module Space::Core
145
151
  return Failure(first_error) if first_error
146
152
 
147
153
  Success(additions.map do |addition|
148
- repo_data = space.add_repo(addition.fetch(:reference), relative_path: addition.fetch(:relative_path), now: now.call)
149
- { space: space, repo: repo_data, reference: addition.fetch(:reference), path: addition.fetch(:path) }
154
+ outcome = addition.fetch(:outcome)
155
+ repo_data =
156
+ if outcome == :present
157
+ # Already tracked: report the existing entry as-is — registration
158
+ # is not re-run (Space#add_repo's guard would rightly refuse).
159
+ addition.fetch(:existing_repo)
160
+ else
161
+ space.add_repo(addition.fetch(:reference), relative_path: addition.fetch(:relative_path), now: now.call)
162
+ end
163
+ { space: space, repo: repo_data, reference: addition.fetch(:reference), path: addition.fetch(:path), outcome: outcome }
150
164
  end)
151
165
  rescue Error => e
152
166
  Failure(e)
@@ -160,7 +174,9 @@ module Space::Core
160
174
 
161
175
  def clone_addition(addition, scm:, cloner:, mise_client:, reporter: nil)
162
176
  reporter&.start(addition)
163
- fetch_addition(addition, scm:, cloner:)
177
+ # :adopted checkouts are already on disk — no fetch of any kind (no
178
+ # scm.clone, no Cloner call); only trust + registration remain.
179
+ fetch_addition(addition, scm:, cloner:) unless addition.fetch(:outcome) == :adopted
164
180
  reporter&.trust(addition)
165
181
  mise_client.trust(addition.fetch(:path))
166
182
  reporter&.finish(addition)
@@ -183,7 +199,19 @@ module Space::Core
183
199
  raise GitError, "clone failed (copy): #{result.failure}" if result.failure?
184
200
  else
185
201
  result = scm.clone(reference.clone_url, destination.to_s)
186
- raise GitError, "clone failed: #{result.failure[:stderr]}" if result.failure?
202
+ return if result.success?
203
+
204
+ stderr = result.failure[:stderr]
205
+ message = "clone failed: #{stderr}"
206
+ # OpenSSH does not localize "Host key verification failed", so it is
207
+ # a stable trigger. Any other failure surfaces the raw stderr as
208
+ # before — fail-fast without a forward path is a support ticket, so
209
+ # teach the flag right where the operator will be looking.
210
+ if stderr.to_s.include?("Host key verification failed")
211
+ message += "\nHost key not in known_hosts; re-run with --trust-host-key to accept it on first contact, " \
212
+ "or add it to your known_hosts manually."
213
+ end
214
+ raise GitError, message
187
215
  end
188
216
  end
189
217
 
@@ -194,13 +222,15 @@ module Space::Core
194
222
  relative_path = Pathname.new("repos").join(reference.directory_name)
195
223
  destination = space.path.join(relative_path)
196
224
 
197
- ensure_repo_can_be_added!(space, reference, relative_path, destination)
225
+ outcome, existing_repo = ensure_repo_can_be_added!(space, reference, relative_path, destination)
198
226
 
199
227
  {
200
228
  reference: reference,
201
229
  relative_path: relative_path,
202
230
  path: destination,
203
- src_source: src_dir && reference.src_path(src_dir)
231
+ src_source: src_dir && reference.src_path(src_dir),
232
+ outcome: outcome,
233
+ existing_repo: existing_repo
204
234
  }
205
235
  end
206
236
 
@@ -216,17 +246,36 @@ module Space::Core
216
246
  additions
217
247
  end
218
248
 
249
+ # Classify one resolved spec for the idempotent-add contract:
250
+ # :added — fetch (COW copy when an evergreen src exists, else
251
+ # clone), then trust, then register (today's path);
252
+ # :present — same full_name already tracked: skip fetch, trust, and
253
+ # registration entirely (no-op success, whether or not the
254
+ # directory is on disk — tracked ⇒ skip);
255
+ # :adopted — destination exists on disk with .git, untracked: trust +
256
+ # register, NO fetch (heals the orphaned checkouts a failed
257
+ # batch leaves behind).
258
+ # Refusals (RepoExistsError): a tracked repo matching on path or name
259
+ # with a DIFFERENT full_name is a conflict, not idempotency, and a
260
+ # destination that exists without .git is not ours to register.
219
261
  def ensure_repo_can_be_added!(space, reference, relative_path, destination)
220
- raise RepoExistsError, "Repo destination already exists: #{destination}" if destination.exist?
262
+ existing = space.repos.find { |repo| repo["full_name"] == reference.full_name }
263
+ return :present, existing if existing
264
+
265
+ conflicting = space.repos.find do |repo|
266
+ repo["path"] == relative_path.to_s || repo["name"] == reference.name
267
+ end
268
+ raise RepoExistsError, "Repo '#{reference.full_name}' already exists in #{space.id}" if conflicting
269
+
270
+ if destination.exist?
271
+ unless destination.join(".git").exist?
272
+ raise RepoExistsError, "Repo destination already exists and is not a git repository: #{destination}"
273
+ end
221
274
 
222
- existing = space.repos.find do |repo|
223
- repo["full_name"] == reference.full_name ||
224
- repo["path"] == relative_path.to_s ||
225
- repo["name"] == reference.name
275
+ return :adopted, nil
226
276
  end
227
- return unless existing
228
277
 
229
- raise RepoExistsError, "Repo '#{reference.full_name}' already exists in #{space.id}"
278
+ [:added, nil]
230
279
  end
231
280
 
232
281
  def metadata_for(id:, title:, timestamp:)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "9.1.0"
5
+ VERSION = "9.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-cadet
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.1.0
4
+ version: 9.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs