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.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +284 -0
  4. data/exe/architect +13 -0
  5. data/exe/space +13 -0
  6. data/lib/space_architect/architect_mission.rb +436 -0
  7. data/lib/space_architect/atomic_write.rb +21 -0
  8. data/lib/space_architect/cli/architect.rb +388 -0
  9. data/lib/space_architect/cli/config.rb +61 -0
  10. data/lib/space_architect/cli/current.rb +22 -0
  11. data/lib/space_architect/cli/helpers.rb +117 -0
  12. data/lib/space_architect/cli/init.rb +35 -0
  13. data/lib/space_architect/cli/list.rb +30 -0
  14. data/lib/space_architect/cli/new.rb +43 -0
  15. data/lib/space_architect/cli/options.rb +12 -0
  16. data/lib/space_architect/cli/path.rb +22 -0
  17. data/lib/space_architect/cli/repo.rb +88 -0
  18. data/lib/space_architect/cli/shell.rb +137 -0
  19. data/lib/space_architect/cli/show.rb +27 -0
  20. data/lib/space_architect/cli/space.rb +35 -0
  21. data/lib/space_architect/cli/src.rb +32 -0
  22. data/lib/space_architect/cli/status.rb +39 -0
  23. data/lib/space_architect/cli/use.rb +23 -0
  24. data/lib/space_architect/cli.rb +102 -0
  25. data/lib/space_architect/config.rb +152 -0
  26. data/lib/space_architect/dispatcher.rb +21 -0
  27. data/lib/space_architect/errors.rb +14 -0
  28. data/lib/space_architect/git_client.rb +49 -0
  29. data/lib/space_architect/harness.rb +168 -0
  30. data/lib/space_architect/mise_client.rb +37 -0
  31. data/lib/space_architect/repo_reference.rb +19 -0
  32. data/lib/space_architect/repo_resolver.rb +167 -0
  33. data/lib/space_architect/shell_integration.rb +438 -0
  34. data/lib/space_architect/slugger.rb +16 -0
  35. data/lib/space_architect/space.rb +110 -0
  36. data/lib/space_architect/space_store.rb +319 -0
  37. data/lib/space_architect/state.rb +86 -0
  38. data/lib/space_architect/templates/architect.md.erb +48 -0
  39. data/lib/space_architect/templates/iteration.md.erb +66 -0
  40. data/lib/space_architect/terminal.rb +163 -0
  41. data/lib/space_architect/version.rb +5 -0
  42. data/lib/space_architect/warnings.rb +13 -0
  43. data/lib/space_architect/xdg.rb +33 -0
  44. data/lib/space_architect.rb +26 -0
  45. data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
  46. data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
  47. data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
  48. data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
  49. data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
  50. data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
  51. data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
  52. data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
  53. data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
  54. data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
  55. data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
  56. data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
  57. data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
  58. data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
  59. data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
  60. data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
  61. data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
  62. data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
  63. data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
  64. data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
  65. data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
  66. data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
  67. data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
  68. data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
  69. data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
  70. data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
  71. data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
  72. data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
  73. data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
  74. data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
  75. data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
  76. data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
  77. data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
  78. data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
  79. data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
  80. 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