space-architect 1.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +284 -0
- data/exe/architect +13 -0
- data/exe/space +13 -0
- data/lib/space_architect/architect_mission.rb +436 -0
- data/lib/space_architect/atomic_write.rb +21 -0
- data/lib/space_architect/cli/architect.rb +388 -0
- data/lib/space_architect/cli/config.rb +61 -0
- data/lib/space_architect/cli/current.rb +22 -0
- data/lib/space_architect/cli/helpers.rb +117 -0
- data/lib/space_architect/cli/init.rb +35 -0
- data/lib/space_architect/cli/list.rb +30 -0
- data/lib/space_architect/cli/new.rb +43 -0
- data/lib/space_architect/cli/options.rb +12 -0
- data/lib/space_architect/cli/path.rb +22 -0
- data/lib/space_architect/cli/repo.rb +88 -0
- data/lib/space_architect/cli/shell.rb +137 -0
- data/lib/space_architect/cli/show.rb +27 -0
- data/lib/space_architect/cli/space.rb +35 -0
- data/lib/space_architect/cli/src.rb +32 -0
- data/lib/space_architect/cli/status.rb +39 -0
- data/lib/space_architect/cli/use.rb +23 -0
- data/lib/space_architect/cli.rb +102 -0
- data/lib/space_architect/config.rb +152 -0
- data/lib/space_architect/dispatcher.rb +21 -0
- data/lib/space_architect/errors.rb +14 -0
- data/lib/space_architect/git_client.rb +49 -0
- data/lib/space_architect/harness.rb +168 -0
- data/lib/space_architect/mise_client.rb +37 -0
- data/lib/space_architect/repo_reference.rb +19 -0
- data/lib/space_architect/repo_resolver.rb +167 -0
- data/lib/space_architect/shell_integration.rb +438 -0
- data/lib/space_architect/slugger.rb +16 -0
- data/lib/space_architect/space.rb +110 -0
- data/lib/space_architect/space_store.rb +319 -0
- data/lib/space_architect/state.rb +86 -0
- data/lib/space_architect/templates/architect.md.erb +48 -0
- data/lib/space_architect/templates/iteration.md.erb +66 -0
- data/lib/space_architect/terminal.rb +163 -0
- data/lib/space_architect/version.rb +5 -0
- data/lib/space_architect/warnings.rb +13 -0
- data/lib/space_architect/xdg.rb +33 -0
- data/lib/space_architect.rb +26 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
- data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
- data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
- data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
- data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
- data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
- metadata +307 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
require "space_architect/pristine/cli"
|
|
6
|
+
require "space_architect/pristine/ui/mode"
|
|
7
|
+
require "space_architect/pristine/cli/options"
|
|
8
|
+
|
|
9
|
+
module SpaceArchitect::Pristine
|
|
10
|
+
module CLI
|
|
11
|
+
# `org` command group: add / remove / list tracked orgs.
|
|
12
|
+
# Same shape as `repo` but against `Config::OrgRef` (host + name
|
|
13
|
+
# + two bool flags). The host defaults to "github.com" per
|
|
14
|
+
# PRD §3.1 when omitted (so `org add socketry` works as well as
|
|
15
|
+
# `org add github.com/socketry`).
|
|
16
|
+
module Org
|
|
17
|
+
module Helpers
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Parse an org ref string. Accepts "github.com/socketry" or
|
|
21
|
+
# bare "socketry" (defaults host to github.com). Anything
|
|
22
|
+
# with 3+ parts is rejected (org has only host + name).
|
|
23
|
+
def parse_ref(name, include_archived: false, include_forks: false, ignored_repos: [])
|
|
24
|
+
parts = name.to_s.split("/")
|
|
25
|
+
case parts.length
|
|
26
|
+
when 1
|
|
27
|
+
org = parts[0]
|
|
28
|
+
return Dry::Monads::Failure("invalid org reference: empty name in #{name.inspect}") if org.empty?
|
|
29
|
+
Dry::Monads::Success(Config::OrgRef.new(
|
|
30
|
+
host: Config::DEFAULT_HOST,
|
|
31
|
+
name: org,
|
|
32
|
+
include_archived: include_archived,
|
|
33
|
+
include_forks: include_forks,
|
|
34
|
+
ignored_repos: ignored_repos
|
|
35
|
+
))
|
|
36
|
+
when 2
|
|
37
|
+
host, n = parts
|
|
38
|
+
return Dry::Monads::Failure("invalid org reference: empty host in #{name.inspect}") if host.empty?
|
|
39
|
+
return Dry::Monads::Failure("invalid org reference: empty name in #{name.inspect}") if n.empty?
|
|
40
|
+
Dry::Monads::Success(Config::OrgRef.new(
|
|
41
|
+
host: host,
|
|
42
|
+
name: n,
|
|
43
|
+
include_archived: include_archived,
|
|
44
|
+
include_forks: include_forks,
|
|
45
|
+
ignored_repos: ignored_repos
|
|
46
|
+
))
|
|
47
|
+
else
|
|
48
|
+
Dry::Monads::Failure("invalid org reference: #{name.inspect} (expected \"<name>\" or \"<host>/<name>\")")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def same_org?(a, b) = a.host == b.host && a.name == b.name
|
|
53
|
+
def format_ref(o) = "#{o.host}/#{o.name}"
|
|
54
|
+
def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
|
|
55
|
+
|
|
56
|
+
def format_ignored(o)
|
|
57
|
+
return "" if o.ignored_repos.empty?
|
|
58
|
+
" ignored_repos=#{o.ignored_repos.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fail_with(cmd, msg)
|
|
62
|
+
cmd.send(:err).puts msg
|
|
63
|
+
SpaceArchitect::Pristine::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class Add < Dry::CLI::Command
|
|
68
|
+
include Helpers
|
|
69
|
+
include GlobalOptions
|
|
70
|
+
|
|
71
|
+
desc "Add a tracked org (idempotent on host/name)"
|
|
72
|
+
argument :name, required: true,
|
|
73
|
+
desc: "Org identity as <name> or <host>/<name> (host defaults to github.com)"
|
|
74
|
+
option :include_archived, type: :boolean, default: false,
|
|
75
|
+
desc: "Include archived repos when expanding the org"
|
|
76
|
+
option :include_forks, type: :boolean, default: false,
|
|
77
|
+
desc: "Include forks when expanding the org"
|
|
78
|
+
option :ignored_repos, type: :array, default: [],
|
|
79
|
+
desc: "Repos to exclude from expansion (bare name or owner/name)"
|
|
80
|
+
|
|
81
|
+
def call(name:, include_archived: false, include_forks: false, ignored_repos: [], plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
82
|
+
mode = UI::Mode.resolve(
|
|
83
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
84
|
+
env: CLI.env,
|
|
85
|
+
out: out
|
|
86
|
+
)
|
|
87
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
88
|
+
|
|
89
|
+
parsed = parse_ref(name,
|
|
90
|
+
include_archived: include_archived,
|
|
91
|
+
include_forks: include_forks,
|
|
92
|
+
ignored_repos: ignored_repos)
|
|
93
|
+
return fail_with(self, parsed.failure) if parsed.failure?
|
|
94
|
+
|
|
95
|
+
new_ref = parsed.success
|
|
96
|
+
paths = CLI.make_paths
|
|
97
|
+
config = Config::Store.load(paths.config_file).success
|
|
98
|
+
|
|
99
|
+
if config.orgs.any? { |o| same_org?(o, new_ref) }
|
|
100
|
+
out.puts pastel.yellow("already tracked: #{format_ref(new_ref)}" \
|
|
101
|
+
" (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})" \
|
|
102
|
+
"#{format_ignored(new_ref)}")
|
|
103
|
+
return CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
result = Config::Store.update(paths.config_file) do |c|
|
|
107
|
+
Config::Store.with(c, orgs: c.orgs + [new_ref])
|
|
108
|
+
end
|
|
109
|
+
if result.failure?
|
|
110
|
+
return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
out.puts pastel.green("added: #{format_ref(new_ref)}" \
|
|
114
|
+
" (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})" \
|
|
115
|
+
"#{format_ignored(new_ref)}")
|
|
116
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class Remove < Dry::CLI::Command
|
|
121
|
+
include Helpers
|
|
122
|
+
include GlobalOptions
|
|
123
|
+
|
|
124
|
+
desc "Remove a tracked org (host/name)"
|
|
125
|
+
argument :name, required: true,
|
|
126
|
+
desc: "Org identity as <name> or <host>/<name> (host defaults to github.com)"
|
|
127
|
+
|
|
128
|
+
def call(name:, plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
129
|
+
# The flags don't affect the identity match for remove; we
|
|
130
|
+
# match on (host, name) only. This matches the user's
|
|
131
|
+
# expectation that "remove" targets the org, not the flag
|
|
132
|
+
# combination they added it with.
|
|
133
|
+
mode = UI::Mode.resolve(
|
|
134
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
135
|
+
env: CLI.env,
|
|
136
|
+
out: out
|
|
137
|
+
)
|
|
138
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
139
|
+
|
|
140
|
+
parsed = parse_ref(name)
|
|
141
|
+
return fail_with(self, parsed.failure) if parsed.failure?
|
|
142
|
+
|
|
143
|
+
target = parsed.success
|
|
144
|
+
paths = CLI.make_paths
|
|
145
|
+
config = Config::Store.load(paths.config_file).success
|
|
146
|
+
|
|
147
|
+
kept = config.orgs.reject { |o| same_org?(o, target) }
|
|
148
|
+
if kept.size == config.orgs.size
|
|
149
|
+
return fail_with(self, "not tracked: #{format_ref(target)}")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
result = Config::Store.update(paths.config_file) do |c|
|
|
153
|
+
Config::Store.with(c, orgs: kept)
|
|
154
|
+
end
|
|
155
|
+
if result.failure?
|
|
156
|
+
return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
out.puts pastel.green("removed: #{format_ref(target)}")
|
|
160
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class List < Dry::CLI::Command
|
|
165
|
+
include Helpers
|
|
166
|
+
include GlobalOptions
|
|
167
|
+
|
|
168
|
+
desc "List tracked orgs"
|
|
169
|
+
|
|
170
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
171
|
+
mode = UI::Mode.resolve(
|
|
172
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
173
|
+
env: CLI.env,
|
|
174
|
+
out: out
|
|
175
|
+
)
|
|
176
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
177
|
+
|
|
178
|
+
paths = CLI.make_paths
|
|
179
|
+
config = Config::Store.load(paths.config_file).success
|
|
180
|
+
if config.orgs.empty?
|
|
181
|
+
out.puts pastel.dim("(no tracked orgs)")
|
|
182
|
+
else
|
|
183
|
+
config.orgs.each do |o|
|
|
184
|
+
out.puts pastel.cyan("#{o.host}/#{o.name}" \
|
|
185
|
+
" (include_archived=#{o.include_archived}, include_forks=#{o.include_forks})" \
|
|
186
|
+
"#{format_ignored(o)}")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
SpaceArchitect::Pristine::CLI::Registry.register "org" do |prefix|
|
|
197
|
+
prefix.register "add", SpaceArchitect::Pristine::CLI::Org::Add
|
|
198
|
+
prefix.register "remove", SpaceArchitect::Pristine::CLI::Org::Remove
|
|
199
|
+
prefix.register "list", SpaceArchitect::Pristine::CLI::Org::List
|
|
200
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
require "space_architect/pristine/cli"
|
|
6
|
+
require "space_architect/pristine/ui/mode"
|
|
7
|
+
require "space_architect/pristine/cli/options"
|
|
8
|
+
|
|
9
|
+
module SpaceArchitect::Pristine
|
|
10
|
+
module CLI
|
|
11
|
+
# `repo` command group: add / remove / list tracked repos.
|
|
12
|
+
# Backed by Config::Store (the on-disk config.yaml is the
|
|
13
|
+
# source of truth — the CLI is just the CRUD interface).
|
|
14
|
+
module Repo
|
|
15
|
+
# Shared ref parser + formatters used by Add and Remove. Kept
|
|
16
|
+
# in a module-level Helpers so the parsing rules live in one
|
|
17
|
+
# place (the "invalid repo reference" message must be identical
|
|
18
|
+
# across add/remove for G3 consistency).
|
|
19
|
+
module Helpers
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def parse_ref(ref)
|
|
23
|
+
parts = ref.to_s.split("/")
|
|
24
|
+
return Dry::Monads::Failure("invalid repo reference: #{ref.inspect} (expected host/owner/name)") if parts.length != 3
|
|
25
|
+
host, owner, name = parts
|
|
26
|
+
return Dry::Monads::Failure("invalid repo reference: empty host in #{ref.inspect}") if host.empty?
|
|
27
|
+
return Dry::Monads::Failure("invalid repo reference: empty owner in #{ref.inspect}") if owner.empty?
|
|
28
|
+
return Dry::Monads::Failure("invalid repo reference: empty name in #{ref.inspect}") if name.empty?
|
|
29
|
+
Dry::Monads::Success(Config::RepoRef.new(host: host, owner: owner, name: name))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def same_repo?(a, b)
|
|
33
|
+
a.host == b.host && a.owner == b.owner && a.name == b.name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_ref(r) = "#{r.host}/#{r.owner}/#{r.name}"
|
|
37
|
+
|
|
38
|
+
def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
|
|
39
|
+
|
|
40
|
+
def fail_with(cmd, msg)
|
|
41
|
+
cmd.send(:err).puts msg
|
|
42
|
+
SpaceArchitect::Pristine::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Add a tracked repo. Idempotent on the (host, owner, name)
|
|
47
|
+
# triple: adding the same ref twice prints "already tracked"
|
|
48
|
+
# and exits 0 (does NOT write a duplicate).
|
|
49
|
+
class Add < Dry::CLI::Command
|
|
50
|
+
include Helpers
|
|
51
|
+
include GlobalOptions
|
|
52
|
+
|
|
53
|
+
desc "Add a tracked repo (idempotent on host/owner/name)"
|
|
54
|
+
argument :ref, required: true,
|
|
55
|
+
desc: "Repo identity as host/owner/name (e.g. github.com/ruby/ruby)"
|
|
56
|
+
|
|
57
|
+
def call(ref:, plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
58
|
+
mode = UI::Mode.resolve(
|
|
59
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
60
|
+
env: CLI.env,
|
|
61
|
+
out: out
|
|
62
|
+
)
|
|
63
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
64
|
+
|
|
65
|
+
parsed = parse_ref(ref)
|
|
66
|
+
return fail_with(self, parsed.failure) if parsed.failure?
|
|
67
|
+
|
|
68
|
+
new_ref = parsed.success
|
|
69
|
+
paths = CLI.make_paths
|
|
70
|
+
config = Config::Store.load(paths.config_file).success
|
|
71
|
+
|
|
72
|
+
if config.repos.any? { |r| same_repo?(r, new_ref) }
|
|
73
|
+
out.puts pastel.yellow("already tracked: #{format_ref(new_ref)}")
|
|
74
|
+
return CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
result = Config::Store.update(paths.config_file) do |c|
|
|
78
|
+
Config::Store.with(c, repos: c.repos + [new_ref])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if result.failure?
|
|
82
|
+
return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
out.puts pastel.green("added: #{format_ref(new_ref)}")
|
|
86
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Remove a tracked repo. Exits 0 with a clear message if the
|
|
91
|
+
# ref was present; exits 1 with "not tracked" if it wasn't
|
|
92
|
+
# (and the config is untouched in that case).
|
|
93
|
+
class Remove < Dry::CLI::Command
|
|
94
|
+
include Helpers
|
|
95
|
+
include GlobalOptions
|
|
96
|
+
|
|
97
|
+
desc "Remove a tracked repo (host/owner/name)"
|
|
98
|
+
argument :ref, required: true,
|
|
99
|
+
desc: "Repo identity as host/owner/name (e.g. github.com/ruby/ruby)"
|
|
100
|
+
|
|
101
|
+
def call(ref:, plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
102
|
+
mode = UI::Mode.resolve(
|
|
103
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
104
|
+
env: CLI.env,
|
|
105
|
+
out: out
|
|
106
|
+
)
|
|
107
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
108
|
+
|
|
109
|
+
parsed = parse_ref(ref)
|
|
110
|
+
return fail_with(self, parsed.failure) if parsed.failure?
|
|
111
|
+
|
|
112
|
+
target = parsed.success
|
|
113
|
+
paths = CLI.make_paths
|
|
114
|
+
config = Config::Store.load(paths.config_file).success
|
|
115
|
+
|
|
116
|
+
kept = config.repos.reject { |r| same_repo?(r, target) }
|
|
117
|
+
if kept.size == config.repos.size
|
|
118
|
+
return fail_with(self, "not tracked: #{format_ref(target)}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
result = Config::Store.update(paths.config_file) do |c|
|
|
122
|
+
Config::Store.with(c, repos: kept)
|
|
123
|
+
end
|
|
124
|
+
if result.failure?
|
|
125
|
+
return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
out.puts pastel.green("removed: #{format_ref(target)}")
|
|
129
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# List tracked repos. One per line: "host/owner/name".
|
|
134
|
+
class List < Dry::CLI::Command
|
|
135
|
+
include GlobalOptions
|
|
136
|
+
|
|
137
|
+
desc "List tracked repos"
|
|
138
|
+
|
|
139
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
140
|
+
mode = UI::Mode.resolve(
|
|
141
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
142
|
+
env: CLI.env,
|
|
143
|
+
out: out
|
|
144
|
+
)
|
|
145
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
146
|
+
|
|
147
|
+
paths = CLI.make_paths
|
|
148
|
+
config = Config::Store.load(paths.config_file).success
|
|
149
|
+
if config.repos.empty?
|
|
150
|
+
out.puts pastel.dim("(no tracked repos)")
|
|
151
|
+
else
|
|
152
|
+
config.repos.each do |r|
|
|
153
|
+
out.puts pastel.cyan("#{r.host}/#{r.owner}/#{r.name}")
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Register the `repo` group + its subcommands. The block's `prefix`
|
|
164
|
+
# is a Dry::CLI::Registry::Prefix proxy that namespaces the
|
|
165
|
+
# subcommand names under "repo".
|
|
166
|
+
SpaceArchitect::Pristine::CLI::Registry.register "repo" do |prefix|
|
|
167
|
+
prefix.register "add", SpaceArchitect::Pristine::CLI::Repo::Add
|
|
168
|
+
prefix.register "remove", SpaceArchitect::Pristine::CLI::Repo::Remove
|
|
169
|
+
prefix.register "list", SpaceArchitect::Pristine::CLI::Repo::List
|
|
170
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "space_architect/pristine/cli"
|
|
5
|
+
require "space_architect/pristine/ui/mode"
|
|
6
|
+
require "space_architect/pristine/cli/options"
|
|
7
|
+
|
|
8
|
+
module SpaceArchitect::Pristine
|
|
9
|
+
module CLI
|
|
10
|
+
# `status` command: render a per-repo evergreen table from
|
|
11
|
+
# State::Store. Per G5, the rows must include each repo key
|
|
12
|
+
# with status, last_synced_at, and default_branch.
|
|
13
|
+
module Status
|
|
14
|
+
class Show < Dry::CLI::Command
|
|
15
|
+
include GlobalOptions
|
|
16
|
+
|
|
17
|
+
STATUS_COLORS = {
|
|
18
|
+
"clean" => :green,
|
|
19
|
+
"dirty" => :yellow,
|
|
20
|
+
"diverged" => :yellow,
|
|
21
|
+
"wrong_branch" => :yellow,
|
|
22
|
+
"detached" => :yellow,
|
|
23
|
+
"error" => :red
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
desc "Show the per-repo evergreen status table (from $XDG_STATE_HOME/repo-tender/state.yaml)"
|
|
27
|
+
|
|
28
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
29
|
+
mode = UI::Mode.resolve(
|
|
30
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
31
|
+
env: CLI.env,
|
|
32
|
+
out: out
|
|
33
|
+
)
|
|
34
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
35
|
+
|
|
36
|
+
paths = CLI.make_paths
|
|
37
|
+
state = State::Store.load(paths.state_file).success
|
|
38
|
+
|
|
39
|
+
if state.repos.empty?
|
|
40
|
+
out.puts "(no repos in state — run `repo-tender sync` to populate)"
|
|
41
|
+
return CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Tab-separated columns. The G5 assertion is on captured
|
|
45
|
+
# stdout containing each repo key and its status string —
|
|
46
|
+
# tab-separated is the most assertion-friendly (no width
|
|
47
|
+
# padding complexity, no ANSI codes).
|
|
48
|
+
out.puts ["REPO", "STATUS", "DEFAULT_BRANCH", "LAST_SYNCED_AT", "LAST_FETCH_AT"].join("\t")
|
|
49
|
+
state.repos.keys.sort.each do |key|
|
|
50
|
+
r = state.repos[key]
|
|
51
|
+
status_str = r.status.to_s
|
|
52
|
+
color = STATUS_COLORS[status_str]
|
|
53
|
+
styled_status = color ? pastel.decorate(status_str, color) : status_str
|
|
54
|
+
out.puts [
|
|
55
|
+
key,
|
|
56
|
+
styled_status,
|
|
57
|
+
r.default_branch.to_s,
|
|
58
|
+
format_time(r.last_synced_at),
|
|
59
|
+
format_time(r.last_fetch_at)
|
|
60
|
+
].join("\t")
|
|
61
|
+
end
|
|
62
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def format_time(t)
|
|
68
|
+
return "" if t.nil?
|
|
69
|
+
t.respond_to?(:iso8601) ? t.iso8601 : t.to_s
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
SpaceArchitect::Pristine::CLI::Registry.register "status", SpaceArchitect::Pristine::CLI::Status::Show
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require "space_architect/pristine/cli"
|
|
5
|
+
require "space_architect/pristine/cli/repo" # for Repo::Helpers.parse_ref
|
|
6
|
+
require "space_architect/pristine/cli/options"
|
|
7
|
+
require "space_architect/pristine/ui/mode"
|
|
8
|
+
require "space_architect/pristine/ui/plain_reporter"
|
|
9
|
+
require "space_architect/pristine/ui/json_reporter"
|
|
10
|
+
require "space_architect/pristine/ui/interactive_reporter"
|
|
11
|
+
|
|
12
|
+
module SpaceArchitect::Pristine
|
|
13
|
+
module CLI
|
|
14
|
+
# `sync` command: invoke Sync::Engine over the full config, or
|
|
15
|
+
# scope to a single repo with --repo.
|
|
16
|
+
#
|
|
17
|
+
# Scoping is implemented at the CLI layer (per gate G4): the
|
|
18
|
+
# CLI builds a filtered Config (Config::Store.with(config,
|
|
19
|
+
# repos: [match], orgs: [])) and passes it to the unchanged
|
|
20
|
+
# engine. Sync::Engine#call is (config:, paths:) — there is no
|
|
21
|
+
# scoping parameter on the engine, and the spec forbids editing
|
|
22
|
+
# sync/engine.rb in this slice.
|
|
23
|
+
module Sync
|
|
24
|
+
class Run < Dry::CLI::Command
|
|
25
|
+
include GlobalOptions
|
|
26
|
+
|
|
27
|
+
desc "Run one sync pass (use --repo to scope to a single tracked repo)"
|
|
28
|
+
option :repo, desc: "Scope to a single tracked repo (host/owner/name)"
|
|
29
|
+
|
|
30
|
+
def call(repo: nil, plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
31
|
+
paths = CLI.make_paths
|
|
32
|
+
paths.ensure!
|
|
33
|
+
config = Config::Store.load(paths.config_file).success
|
|
34
|
+
|
|
35
|
+
# Log rotation pre-step (slice-4 gate G5). launchd owns
|
|
36
|
+
# the stdout/stderr redirect via StandardOutPath /
|
|
37
|
+
# StandardErrorPath; the sync process rotates those
|
|
38
|
+
# files at the start of each run so the previous run's
|
|
39
|
+
# log doesn't grow unbounded. The rotator renames the
|
|
40
|
+
# file to a timestamped archive (preserving bytes); the
|
|
41
|
+
# current process's inherited fd still points to the
|
|
42
|
+
# renamed file, so writes during this run succeed.
|
|
43
|
+
# launchd opens a fresh file at the original path on
|
|
44
|
+
# the next spawn. No-op when the log is missing or
|
|
45
|
+
# under-threshold (sync tests in G4 stay green).
|
|
46
|
+
rotate_plist_logs(paths)
|
|
47
|
+
if repo
|
|
48
|
+
target = scope_target(repo)
|
|
49
|
+
return fail_with(self, "invalid repo reference: #{repo.inspect} (expected host/owner/name)") if target.failure?
|
|
50
|
+
|
|
51
|
+
match = target.success
|
|
52
|
+
found = config.repos.find { |r| Repo::Helpers.same_repo?(r, match) }
|
|
53
|
+
if found.nil?
|
|
54
|
+
return fail_with(self, "no such tracked repo: #{Repo::Helpers.format_ref(match)}")
|
|
55
|
+
end
|
|
56
|
+
# Filtered config: only the one matched repo, no orgs
|
|
57
|
+
# (org expansion would discover other repos — that's
|
|
58
|
+
# exactly the G4 "other repo gets no state row" test
|
|
59
|
+
# path, so we explicitly empty orgs here).
|
|
60
|
+
config = Config::Store.with(config, repos: [found], orgs: [])
|
|
61
|
+
out.puts "scoping sync to: #{Repo::Helpers.format_ref(found)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
mode = UI::Mode.resolve(
|
|
65
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
66
|
+
env: CLI.env,
|
|
67
|
+
out: out
|
|
68
|
+
)
|
|
69
|
+
reporter = if mode.format == :json
|
|
70
|
+
UI::JsonReporter.new(out)
|
|
71
|
+
elsif mode.animate
|
|
72
|
+
UI::InteractiveReporter.new(out, mode: mode)
|
|
73
|
+
else
|
|
74
|
+
UI::PlainReporter.new(out, mode: mode)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
result = SpaceArchitect::Pristine::Sync::Engine.new(reporter: reporter).call(config: config, paths: paths)
|
|
78
|
+
if result.failure?
|
|
79
|
+
return fail_with(self, "sync failed: #{format_failure(result.failure)}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
new_state = result.success
|
|
83
|
+
n = new_state.repos.size
|
|
84
|
+
out.puts "synced #{n} repo(s)"
|
|
85
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def scope_target(repo)
|
|
91
|
+
Repo::Helpers.parse_ref(repo)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
|
|
95
|
+
|
|
96
|
+
def fail_with(cmd, msg)
|
|
97
|
+
cmd.send(:err).puts msg
|
|
98
|
+
SpaceArchitect::Pristine::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Default log-rotation threshold: 10 MiB. Tunable via the
|
|
102
|
+
# env var `REPO_TENDER_LOG_MAX_BYTES` (introspection /
|
|
103
|
+
# ops escape hatch). The LogRotator itself is unit-tested
|
|
104
|
+
# with an injected threshold (gate G5).
|
|
105
|
+
DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024
|
|
106
|
+
|
|
107
|
+
def rotate_plist_logs(paths)
|
|
108
|
+
threshold = log_max_bytes
|
|
109
|
+
label = Launchd::Agent::DEFAULT_LABEL
|
|
110
|
+
[File.join(paths.log_dir, "#{label}.out.log"),
|
|
111
|
+
File.join(paths.log_dir, "#{label}.err.log")].each do |p|
|
|
112
|
+
SpaceArchitect::Pristine::LogRotator.call(p, threshold_bytes: threshold)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# CF6 (Slice 5): defensively parse the
|
|
117
|
+
# `REPO_TENDER_LOG_MAX_BYTES` env var so a malformed
|
|
118
|
+
# operator value (e.g. `"10MB"`) falls back to the
|
|
119
|
+
# 10 MiB default instead of raising `ArgumentError`
|
|
120
|
+
# and crashing the entire `sync` run before any repo
|
|
121
|
+
# work.
|
|
122
|
+
#
|
|
123
|
+
# Accepted: any positive integer in base 10
|
|
124
|
+
# (e.g. `"1048576"`, `" 524288 "`). Falls back to
|
|
125
|
+
# `DEFAULT_LOG_MAX_BYTES` (and emits a single
|
|
126
|
+
# `Kernel#warn` to stderr) for: unset, empty,
|
|
127
|
+
# whitespace, non-numeric (`"10MB"`, `"abc"`), zero,
|
|
128
|
+
# and negative inputs. Never raises.
|
|
129
|
+
#
|
|
130
|
+
# The optional `env_value` arg exists so the unit
|
|
131
|
+
# tests can pass arbitrary values without mutating
|
|
132
|
+
# the real `ENV`; production callers invoke with
|
|
133
|
+
# no args and the method reads `ENV` itself.
|
|
134
|
+
def log_max_bytes(env_value = ENV["REPO_TENDER_LOG_MAX_BYTES"])
|
|
135
|
+
return DEFAULT_LOG_MAX_BYTES if env_value.nil? || env_value.strip.empty?
|
|
136
|
+
|
|
137
|
+
parsed = Integer(env_value, 10, exception: false)
|
|
138
|
+
return parsed if parsed.is_a?(Integer) && parsed.positive?
|
|
139
|
+
|
|
140
|
+
warn "repo-tender: REPO_TENDER_LOG_MAX_BYTES=#{env_value.inspect} is invalid; " \
|
|
141
|
+
"falling back to #{DEFAULT_LOG_MAX_BYTES} bytes"
|
|
142
|
+
DEFAULT_LOG_MAX_BYTES
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
SpaceArchitect::Pristine::CLI::Registry.register "sync", SpaceArchitect::Pristine::CLI::Sync::Run
|