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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "async"
5
+ require "space_architect/pristine/shell"
6
+ require "space_architect/pristine/launchd/plist"
7
+
8
+ module SpaceArchitect::Pristine
9
+ module Launchd
10
+ # launchctl wrapper. Holds an injected command runner (the
11
+ # real default goes through `SpaceArchitect::Pristine::Shell` inside a
12
+ # `Sync{}` block; tests inject a `RecordingRunner` that
13
+ # captures argv and returns canned output — gate G2).
14
+ #
15
+ # All public methods return `Dry::Monads::Result`. A non-zero
16
+ # `launchctl` exit surfaces as `Failure({argv:, stderr:,
17
+ # status:})` — the same shape `Shell.run` uses — NOT a
18
+ # raise. A non-zero `runner` exit propagates as `Failure` to
19
+ # the caller.
20
+ #
21
+ # Domain: every operation targets `gui/<UID>` (the user's
22
+ # per-GUI-session launchd domain — the conventional domain
23
+ # for user-installed LaunchAgents on macOS). The UID is
24
+ # resolved via `Process.uid` by default; tests may inject
25
+ # a different UID to assert the exact argv (G2).
26
+ class Agent
27
+ extend Dry::Monads[:result]
28
+
29
+ DEFAULT_LABEL = "io.github.jetpks.repo-tender.sync"
30
+
31
+ # The default real-runner. Wraps `Shell.run` in a `Sync{}`
32
+ # block so the Fiber-scheduler requirement is satisfied.
33
+ # Outside an ambient Async::Task, `Shell.run` would raise;
34
+ # the wrapper creates one. This is the only place the
35
+ # production code path touches `Shell` for launchctl.
36
+ class ShellRunner
37
+ def run(*argv, **opts)
38
+ Sync do |_task|
39
+ if opts.empty?
40
+ Shell.run(*argv)
41
+ else
42
+ Shell.run(*argv, **opts)
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def initialize(runner: ShellRunner.new, uid: Process.uid, label: DEFAULT_LABEL)
49
+ @runner = runner
50
+ @uid = uid
51
+ @label = label
52
+ end
53
+
54
+ attr_reader :label
55
+
56
+ # `launchctl bootstrap gui/<UID> <abs-plist-path>`
57
+ def install(plist_path)
58
+ run("bootstrap", "gui/#{@uid}", plist_path)
59
+ end
60
+
61
+ # `launchctl bootout gui/<UID>/<label>`
62
+ #
63
+ # Idempotency (Slice 5 / CF5): a benign bootout Failure
64
+ # (status 3 / "No such process" / "Could not find
65
+ # specified service") is mapped to **Success** —
66
+ # uninstalling a not-loaded agent is a no-op for the
67
+ # bootout step. The plist removal in the CLI command
68
+ # layer is independent of this result.
69
+ def uninstall
70
+ r = run("bootout", "gui/#{@uid}/#{@label}")
71
+ return Dry::Monads::Success("") if benign_bootout_failure?(r)
72
+ r
73
+ end
74
+
75
+ # bootstrap the plist, then `enable` the service.
76
+ # Both must succeed (both 0 exit) for the operation to
77
+ # be a `Success`; the first failure short-circuits.
78
+ def start(plist_path)
79
+ r1 = run("bootstrap", "gui/#{@uid}", plist_path)
80
+ return r1 if r1.failure?
81
+ run("enable", "gui/#{@uid}/#{@label}")
82
+ end
83
+
84
+ # bootout the service, then `disable` it.
85
+ #
86
+ # Idempotency (Slice 5 / CF5): a `bootout` Failure with
87
+ # `status == 3` ("No such process") or matching the
88
+ # not-loaded stderr is treated as **already not loaded**
89
+ # and is not propagated — the disable step still runs so
90
+ # the persistent `disable` override stays in place
91
+ # (matching the gate's recorded-argv assertion
92
+ # `[[bootout,…], [disable,…]]` and the
93
+ # "stopped" semantic). A non-benign bootout Failure
94
+ # (e.g. status 1 "Operation not permitted") short-
95
+ # circuits as before.
96
+ def stop
97
+ r1 = run("bootout", "gui/#{@uid}/#{@label}")
98
+ return r1 if r1.failure? && !benign_bootout_failure?(r1)
99
+ run("disable", "gui/#{@uid}/#{@label}")
100
+ end
101
+
102
+ # `launchctl kickstart -k gui/<UID>/<label>` — `-k` kills
103
+ # the running instance first so the new one always starts.
104
+ def restart
105
+ run("kickstart", "-k", "gui/#{@uid}/#{@label}")
106
+ end
107
+
108
+ # Returns a defensive parse of `launchctl list` (the
109
+ # machine-readable form — `launchctl print` is documented
110
+ # as "not API"). We run `launchctl list` (no service
111
+ # target) and search the output for our label.
112
+ #
113
+ # The parser tolerates: empty output, a "Could not find"
114
+ # line, malformed rows, and PID values that are not
115
+ # integers. On any of those, we return Success(loaded:
116
+ # false) — the gate G4 "no raise on malformed" guarantee.
117
+ def status
118
+ result = run("list")
119
+ return result if result.failure?
120
+ parse_list(result.success)
121
+ end
122
+
123
+ # ----- internal: argv dispatch + list parser -----
124
+
125
+ private
126
+
127
+ # CF5: a `bootout` Failure whose `status == 3` ("No such
128
+ # process") OR whose stderr matches the
129
+ # not-loaded markers is **not a real failure** — the
130
+ # service is simply not currently loaded, which is the
131
+ # common case at a 6h refresh interval. We key on
132
+ # `argv[1] == "bootout"` so the benign mapping is
133
+ # strictly scoped to bootout (bootstrap status-3
134
+ # remains a real Failure — gate G3 regression guard).
135
+ #
136
+ # Status 3 is the POSIX `ESRCH` errno (`launchctl error 3`
137
+ # → "No such process") and is the documented signal.
138
+ # The stderr regex is the defensive OR — `launchctl`
139
+ # stderr text is NOT API and may drift; we accept both
140
+ # observed phrasings ("No such process" from recent
141
+ # macOS, "Could not find specified service" from older
142
+ # releases / the legacy `unload` path).
143
+ def benign_bootout_failure?(result)
144
+ return false unless result.failure?
145
+
146
+ f = result.failure
147
+ return false unless f.is_a?(Hash)
148
+
149
+ argv = f[:argv]
150
+ return false unless argv.is_a?(Array) && argv[1] == "bootout"
151
+
152
+ return true if f[:status] == 3
153
+ stderr = f[:stderr].to_s
154
+ stderr.match?(/No such process|Could not find specified service/i)
155
+ end
156
+
157
+ # Every operation is a `launchctl` subcommand — the program
158
+ # name must be argv[0] so the runner (real `Shell.run` →
159
+ # Open3) actually execs `launchctl`, not the bare subcommand.
160
+ def run(*argv)
161
+ @runner.run("launchctl", *argv)
162
+ end
163
+
164
+ # Parses the tabular output of `launchctl list`. Each line
165
+ # is three tab-separated fields: PID, Status (last exit
166
+ # code), Label. PID is "-" if the job is not currently
167
+ # running. A line whose label matches ours is the row we
168
+ # want; everything else is ignored.
169
+ def parse_list(output)
170
+ return Dry::Monads::Success({loaded: false, running: false, last_exit: nil, pid: nil}) if output.nil? || output.empty?
171
+
172
+ output.each_line do |line|
173
+ fields = line.chomp.split("\t", 3)
174
+ next if fields.length < 3
175
+ pid_s, status_s, lbl = fields
176
+ next unless lbl == @label
177
+ pid = (pid_s == "-" || pid_s.nil? || pid_s.empty?) ? nil : Integer(pid_s, exception: false)
178
+ status = (status_s == "-" || status_s.nil? || status_s.empty?) ? nil : Integer(status_s, exception: false)
179
+ return Dry::Monads::Success({
180
+ loaded: true,
181
+ running: !pid.nil?,
182
+ pid: pid,
183
+ last_exit: status
184
+ })
185
+ end
186
+
187
+ Dry::Monads::Success({loaded: false, running: false, last_exit: nil, pid: nil})
188
+ rescue => e
189
+ # Defensive: any unexpected parse failure is reported as
190
+ # "unknown" — NOT a raise (gate G4).
191
+ Dry::Monads::Success({loaded: false, running: false, last_exit: nil, pid: nil, error: e.message})
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect::Pristine
4
+ module Launchd
5
+ # Hand-rolled launchd plist emitter. The slice forbids a plist
6
+ # gem (PRD §2, AGENTS.md) — this class emits an XML property
7
+ # list as a string and is validated offline with `plutil -lint`.
8
+ #
9
+ # The plist produced here is a fixed-shape StartInterval-driven
10
+ # agent that:
11
+ # * runs `repo-tender sync` non-interactively under the
12
+ # repo's mise-managed Ruby (so the right toolchain is in
13
+ # effect without `mise activate`, which is broken
14
+ # non-interactively);
15
+ # * is classified as a Background process (lower scheduling
16
+ # + I/O priority — sync is a periodic background job);
17
+ # * writes its stdout/stderr to absolute log files under
18
+ # the log dir (launchd owns the redirect, the sync process
19
+ # rotates its own log on each run — see LogRotator);
20
+ # * has NO `KeepAlive` key — it is a periodic, not a daemon.
21
+ #
22
+ # The caller is responsible for passing absolute paths. We do
23
+ # NOT `File.expand_path` here — that would mask the caller's
24
+ # responsibility to pass absolute paths (the G1 / G3 gates
25
+ # assert that no `~` or `$HOME` appears in any value).
26
+ class Plist
27
+ # The plist's outer XML decl + DOCTYPE — required by
28
+ # plutil's lint and by launchd's parser.
29
+ HEADER = <<~XML
30
+ <?xml version="1.0" encoding="UTF-8"?>
31
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
32
+ <plist version="1.0">
33
+ <dict>
34
+ XML
35
+ FOOTER = "</dict>\n</plist>\n"
36
+
37
+ class << self
38
+ # Emit a launchd plist for a sync job.
39
+ #
40
+ # @param label [String] The job label (must be a valid reverse-DNS string; appears as the basename of the on-disk plist).
41
+ # @param refresh_interval [Integer] StartInterval in seconds (must be > 0).
42
+ # @param log_dir [String] Absolute directory for the standard-out / standard-err logs.
43
+ # @param repo_root [String] Absolute path to set as WorkingDirectory (so mise finds the repo's mise.toml).
44
+ # @param mise_toml [String] Absolute path to mise.toml (pinned via EnvironmentVariables.MISE_CONFIG_FILE).
45
+ # @param mise_bin [String] Absolute path to the mise binary (ProgramArguments[0]).
46
+ # @param ruby_bin [String] Absolute path to the ruby to run the script under.
47
+ # @param bin_path [String] Absolute path to the repo-tender bin script.
48
+ # @return [String] The full plist XML, ready to be written to disk and `plutil -lint`-validated.
49
+ def call(label:, refresh_interval:, log_dir:, repo_root:, mise_toml:, mise_bin:, ruby_bin:, bin_path:)
50
+ raise ArgumentError, "label is required" if label.to_s.empty?
51
+ raise ArgumentError, "refresh_interval must be > 0" unless refresh_interval.is_a?(Integer) && refresh_interval > 0
52
+ %w[log_dir repo_root mise_toml mise_bin ruby_bin bin_path].each do |k|
53
+ v = binding.local_variable_get(k)
54
+ raise ArgumentError, "#{k} must be absolute (got #{v.inspect})" unless v.is_a?(String) && File.absolute_path?(v)
55
+ end
56
+
57
+ out_log = File.join(log_dir, "#{label}.out.log")
58
+ err_log = File.join(log_dir, "#{label}.err.log")
59
+
60
+ body = +""
61
+ body << key("Label") << string(label) << "\n"
62
+ body << key("ProgramArguments") << "\n" << array([
63
+ mise_bin,
64
+ "exec",
65
+ "--",
66
+ ruby_bin,
67
+ bin_path,
68
+ "sync"
69
+ ])
70
+ body << key("WorkingDirectory") << string(repo_root) << "\n"
71
+ body << key("EnvironmentVariables") << "\n" << dict({
72
+ "MISE_CONFIG_FILE" => mise_toml
73
+ })
74
+ body << key("StartInterval") << integer(refresh_interval) << "\n"
75
+ body << key("RunAtLoad") << boolean(true) << "\n"
76
+ body << key("ProcessType") << string("Background") << "\n"
77
+ body << key("StandardOutPath") << string(out_log) << "\n"
78
+ body << key("StandardErrorPath") << string(err_log) << "\n"
79
+
80
+ HEADER + body + FOOTER
81
+ end
82
+
83
+ private
84
+
85
+ def key(name)
86
+ " <key>#{escape(name)}</key>\n"
87
+ end
88
+
89
+ # XML-escape: & must be first, then <, >, " and ' in attribute values
90
+ # (we only emit element text, but be safe for both).
91
+ def escape(s)
92
+ s.to_s
93
+ .gsub("&", "&amp;")
94
+ .gsub("<", "&lt;")
95
+ .gsub(">", "&gt;")
96
+ .gsub("\"", "&quot;")
97
+ .gsub("'", "&apos;")
98
+ end
99
+
100
+ def string(s)
101
+ " <string>#{escape(s)}</string>"
102
+ end
103
+
104
+ def integer(i)
105
+ " <integer>#{Integer(i)}</integer>"
106
+ end
107
+
108
+ def boolean(b)
109
+ " <#{b ? "true" : "false"}/>"
110
+ end
111
+
112
+ def array(items)
113
+ out = +" <array>\n"
114
+ items.each { |arg| out << " <string>#{escape(arg)}</string>\n" }
115
+ out << " </array>"
116
+ end
117
+
118
+ def dict(hash)
119
+ out = +" <dict>\n"
120
+ hash.each do |k, v|
121
+ out << " <key>#{escape(k)}</key>\n"
122
+ out << " <string>#{escape(v)}</string>\n"
123
+ end
124
+ out << " </dict>"
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require "dry/monads"
6
+
7
+ module SpaceArchitect::Pristine
8
+ # Rotates a log file when it exceeds a byte threshold. The
9
+ # archive's filename embeds an ISO-8601-compact timestamp
10
+ # (`YYYYMMDDTHHMMSSZ`) of the rotation event (the injected
11
+ # `now` — deterministic in tests, real `Time.now` in
12
+ # production). The rename is byte-for-byte — no copy, no
13
+ # data loss.
14
+ #
15
+ # The sync process calls this at the top of `Run#call` to
16
+ # rotate the two plist log paths (`<label>.out.log` and
17
+ # `<label>.err.log`). launchd opens those files fresh on the
18
+ # next spawn; the current process's inherited fd still points
19
+ # to the renamed file (writes succeed; the file is the
20
+ # archive). After the process exits, launchd re-opens a new
21
+ # file at the original path. This is the mechanism the spec
22
+ # asked for (gate G5).
23
+ class LogRotator
24
+ extend Dry::Monads[:result]
25
+
26
+ # @param log_path [String] absolute path to the log file to potentially rotate
27
+ # @param threshold_bytes [Integer] rotation threshold; > this many bytes ⇒ rotate
28
+ # @param now [Time] the timestamp to embed in the archive filename
29
+ # @return [Dry::Monads::Result<Hash>] Success({rotated: bool, archive_path: String|nil})
30
+ def self.call(log_path, threshold_bytes:, now: Time.now)
31
+ return Success({rotated: false, archive_path: nil, reason: "missing"}) unless File.exist?(log_path)
32
+ return Success({rotated: false, archive_path: nil, reason: "under_threshold"}) if File.size(log_path) <= threshold_bytes
33
+
34
+ archive = build_archive_path(log_path, now)
35
+ FileUtils.mv(log_path, archive)
36
+ Success({rotated: true, archive_path: archive, reason: "oversized"})
37
+ rescue => e
38
+ Failure({log_path: log_path, error: e.class.name, message: e.message})
39
+ end
40
+
41
+ def self.build_archive_path(log_path, now)
42
+ ts = now.utc.strftime("%Y%m%dT%H%M%SZ")
43
+ "#{log_path}.#{ts}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "xdg"
4
+ require "fileutils"
5
+
6
+ module SpaceArchitect::Pristine
7
+ # XDG-aware path resolution. Honors $XDG_CONFIG_HOME / $XDG_STATE_HOME
8
+ # overrides (and a caller-supplied environment hash for testability);
9
+ # otherwise falls back to the XDG defaults (~/.config, ~/.local/state).
10
+ #
11
+ # `base_dir` is the on-disk home for the src clones
12
+ # ($BASE/:host/:owner/:repo). It defaults to ~/architect/src and is
13
+ # resolved from the config at call time (passed in as an argument here
14
+ # so this module owns nothing about config storage).
15
+ class Paths
16
+ APP_NAME = "repo-tender"
17
+
18
+ DEFAULT_BASE_DIR = File.expand_path("~/architect/src")
19
+
20
+ def initialize(environment: ENV, base_dir: nil)
21
+ @environment = environment
22
+ @base_dir = base_dir
23
+ end
24
+
25
+ def config_home = xdg.config_home.to_s
26
+
27
+ def state_home = xdg.state_home.to_s
28
+
29
+ def config_dir = File.join(config_home, APP_NAME)
30
+
31
+ def config_file = File.join(config_dir, "config.yaml")
32
+
33
+ def state_dir = File.join(state_home, APP_NAME)
34
+
35
+ def state_file = File.join(state_dir, "state.yaml")
36
+
37
+ def log_dir = File.join(state_dir, "logs")
38
+
39
+ # Per-user launchd agent directory. The user's
40
+ # `~/Library/LaunchAgents/` (PRD §3.2; slice-4 Lane 01). The
41
+ # path is resolved from the env's HOME so tests can inject a
42
+ # temp HOME via `Paths.new(environment:)` and assert that
43
+ # install/uninstall NEVER writes to the real
44
+ # `~/Library/LaunchAgents` (gate G3).
45
+ def launch_agents_dir
46
+ home = @environment["HOME"] || Dir.home
47
+ File.join(home, "Library", "LaunchAgents")
48
+ end
49
+
50
+ # Default `base_dir` is ~/architect/src. Callers may override by passing
51
+ # one to the constructor (e.g. from loaded config).
52
+ def base_dir
53
+ @base_dir || DEFAULT_BASE_DIR
54
+ end
55
+
56
+ # Ensure the on-disk directories exist. The config file itself is
57
+ # optional and created lazily by Config::Store; we only ensure parent
58
+ # dirs. Idempotent.
59
+ def ensure!
60
+ FileUtils.mkdir_p(config_dir)
61
+ FileUtils.mkdir_p(state_dir)
62
+ FileUtils.mkdir_p(log_dir)
63
+ self
64
+ end
65
+
66
+ private
67
+
68
+ def xdg
69
+ @xdg ||= XDG::Environment.new(environment: @environment)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module SpaceArchitect::Pristine
6
+ module SCM
7
+ # Abstract SCM interface. The git CLI is the only implementation for
8
+ # now (per AGENTS.md / PRD §1), but the sync engine + tests must
9
+ # code to this interface so a future SCM is a drop-in.
10
+ #
11
+ # Every method returns a Dry::Monads::Result. Programmer error
12
+ # (e.g. nil path) is a raise; expected failures (no .git, non-fast-
13
+ # forward, network down) are Failure.
14
+ class Client
15
+ extend Dry::Monads[:result]
16
+
17
+ # Parse the working-tree status of a repo on disk. Implementation
18
+ # must read porcelain v2 (per AGENTS.md gotcha) and report a
19
+ # `SCM::Status` value object.
20
+ def status(path)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ # Returns the current branch name, or nil if HEAD is detached.
25
+ def current_branch(path)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ # Returns the bare remote's HEAD (e.g. "main" or "trunk"). Must
30
+ # work even when the default branch is not "main". May do a
31
+ # one-shot network call (`git remote set-head origin -a`) to
32
+ # refresh a stale `origin/HEAD`.
33
+ def default_branch(path)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ # Returns the mtime of .git/FETCH_HEAD, or nil if absent. Treated
38
+ # as a freshness hint (PRD §3.3 step 4 + gate G5).
39
+ def last_fetch_at(path)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ # `git fetch --prune --no-tags origin`.
44
+ def fetch(path)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ # `git merge --ff-only origin/<default>`. Returns Failure if the
49
+ # local branch has diverged (left count > 0) — never resets.
50
+ # On Success: returns Integer commit count pulled.
51
+ # 0 → already up to date (no merge performed)
52
+ # N → fast-forwarded N commits
53
+ def fast_forward(path, default_branch)
54
+ raise NotImplementedError
55
+ end
56
+
57
+ # `git clone <url> <path>`.
58
+ def clone(url, path)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ # `git switch <branch>`. Switches the local repo to the given
63
+ # branch. By default `git switch` refuses to clobber a dirty
64
+ # working tree (the operation is aborted on local-change loss
65
+ # per `man git-switch`); the engine treats that as a Failure.
66
+ # The engine is responsible for the upstream dirty-tree guard
67
+ # (per Slice 2 gate G5 / PHASE-0 ruling): the plan returns
68
+ # `:report_wrong_branch` / `:report_detached` for a dirty tree,
69
+ # so this method is only called on clean trees.
70
+ def switch(path, branch)
71
+ raise NotImplementedError
72
+ end
73
+
74
+ # Sync an unborn (empty) local clone. Called when the repo has no
75
+ # commits (`status.unborn? == true`) and the working tree is clean.
76
+ # Returns Success(:empty) when the remote has no branches (valid
77
+ # empty clone; no mutation). Returns Success(:fast_forwarded) when
78
+ # the remote has gained commits and the local clone was advanced to
79
+ # them. Returns Failure on a real network/probe error (the
80
+ # empty-vs-error distinction is made via `git ls-remote --heads
81
+ # origin`: exit 0 = definitive answer; non-zero = real error).
82
+ def sync_empty(path)
83
+ raise NotImplementedError
84
+ end
85
+ end
86
+ end
87
+ end