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,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require "space_architect/pristine/scm/client"
|
|
6
|
+
require "space_architect/pristine/scm/status"
|
|
7
|
+
require "space_architect/pristine/shell"
|
|
8
|
+
|
|
9
|
+
module SpaceArchitect::Pristine
|
|
10
|
+
module SCM
|
|
11
|
+
# Git CLI implementation of SCM::Client. All subprocess work is
|
|
12
|
+
# delegated to Shell.run (which requires an ambient Async::Task).
|
|
13
|
+
class Git < Client
|
|
14
|
+
# Resolve the bare remote's HEAD. First try the local
|
|
15
|
+
# `origin/HEAD` symbolic ref; if missing/stale, do a one-shot
|
|
16
|
+
# `git remote set-head origin -a` (network) to refresh it, then
|
|
17
|
+
# re-read. This is the gotcha path from AGENTS.md: a plain
|
|
18
|
+
# `git fetch` does NOT update `origin/HEAD`.
|
|
19
|
+
def default_branch(path)
|
|
20
|
+
symbolic = read_origin_head(path)
|
|
21
|
+
return Dry::Monads::Success(symbolic) if symbolic
|
|
22
|
+
|
|
23
|
+
refresh = Shell.run("git", "remote", "set-head", "origin", "-a", chdir: path)
|
|
24
|
+
return refresh if refresh.failure?
|
|
25
|
+
|
|
26
|
+
resolved = read_origin_head(path)
|
|
27
|
+
if resolved
|
|
28
|
+
Dry::Monads::Success(resolved)
|
|
29
|
+
else
|
|
30
|
+
Dry::Monads::Failure({path: path, reason: "could not resolve origin/HEAD after set-head -a"})
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_branch(path)
|
|
35
|
+
# `git symbolic-ref --short HEAD` exits non-zero on detached HEAD.
|
|
36
|
+
result = Shell.run("git", "symbolic-ref", "--short", "HEAD", chdir: path)
|
|
37
|
+
if result.success?
|
|
38
|
+
Dry::Monads::Success(result.success.strip)
|
|
39
|
+
else
|
|
40
|
+
# Detached HEAD is not a hard failure — report nil.
|
|
41
|
+
head = Shell.run("git", "rev-parse", "--verify", "HEAD", chdir: path)
|
|
42
|
+
if head.success?
|
|
43
|
+
Dry::Monads::Success(nil)
|
|
44
|
+
else
|
|
45
|
+
Dry::Monads::Failure({path: path, reason: "no HEAD", stderr: result.failure[:stderr]})
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def last_fetch_at(path)
|
|
51
|
+
fetch_head = File.join(path, ".git", "FETCH_HEAD")
|
|
52
|
+
return Dry::Monads::Success(nil) unless File.exist?(fetch_head)
|
|
53
|
+
Dry::Monads::Success(Time.at(File.mtime(fetch_head).to_i))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fetch(path)
|
|
57
|
+
Shell.run("git", "fetch", "--prune", "--no-tags", "origin", chdir: path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `merge --ff-only` refuses on divergence. We additionally check
|
|
61
|
+
# the rev-list left/right count first so we can surface a clean
|
|
62
|
+
# "diverged" failure with diagnostic info, not just a git error
|
|
63
|
+
# string.
|
|
64
|
+
def fast_forward(path, default_branch)
|
|
65
|
+
upstream = "origin/#{default_branch}"
|
|
66
|
+
counts = Shell.run("git", "rev-list", "--left-right", "--count", "HEAD...#{upstream}", chdir: path)
|
|
67
|
+
return counts if counts.failure?
|
|
68
|
+
|
|
69
|
+
left, right = counts.success.strip.split("\t").map(&:to_i)
|
|
70
|
+
if left > 0
|
|
71
|
+
return Dry::Monads::Failure({
|
|
72
|
+
path: path,
|
|
73
|
+
reason: "diverged: local is #{left} commit(s) ahead of #{upstream}; not auto-resolving",
|
|
74
|
+
local_ahead: left,
|
|
75
|
+
remote_ahead: right
|
|
76
|
+
})
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if right == 0
|
|
80
|
+
return Dry::Monads::Success(0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Do the fetch (cheap; FETCH_HEAD mtime hint logic can skip this
|
|
84
|
+
# later, but Slice 1 always fetches when asked to fast-forward).
|
|
85
|
+
fetch_result = fetch(path)
|
|
86
|
+
return fetch_result if fetch_result.failure?
|
|
87
|
+
|
|
88
|
+
merge = Shell.run("git", "merge", "--ff-only", upstream, chdir: path)
|
|
89
|
+
if merge.success?
|
|
90
|
+
Dry::Monads::Success(right)
|
|
91
|
+
else
|
|
92
|
+
# On --ff-only failure, git leaves the working tree and local
|
|
93
|
+
# commits intact — the test asserts this.
|
|
94
|
+
Dry::Monads::Failure({
|
|
95
|
+
path: path,
|
|
96
|
+
reason: "fast-forward failed (likely raced divergence)",
|
|
97
|
+
stderr: merge.failure[:stderr]
|
|
98
|
+
})
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def status(path)
|
|
103
|
+
result = Shell.run("git", "status", "--porcelain=v2", "--branch", "--untracked-files=normal", chdir: path)
|
|
104
|
+
return result if result.failure?
|
|
105
|
+
|
|
106
|
+
parsed = parse_porcelain_v2(result.success)
|
|
107
|
+
Dry::Monads::Success(parsed)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def clone(url, dest)
|
|
111
|
+
parent = File.dirname(dest)
|
|
112
|
+
FileUtils.mkdir_p(parent)
|
|
113
|
+
result = Shell.run("git", "clone", url, dest)
|
|
114
|
+
if result.success?
|
|
115
|
+
Dry::Monads::Success(dest)
|
|
116
|
+
else
|
|
117
|
+
Dry::Monads::Failure({url: url, dest: dest, stderr: result.failure[:stderr]})
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# `git switch <branch>`. `git switch` aborts on a dirty tree by
|
|
122
|
+
# default (man git-switch: "The operation is aborted however if
|
|
123
|
+
# the operation leads to loss of local changes"), so a nonzero
|
|
124
|
+
# exit here most likely means the caller violated the engine's
|
|
125
|
+
# dirty-tree guard. We surface that as a Failure with the
|
|
126
|
+
# captured stderr so the engine / log can diagnose it.
|
|
127
|
+
def switch(path, branch)
|
|
128
|
+
result = Shell.run("git", "switch", branch, chdir: path)
|
|
129
|
+
if result.success?
|
|
130
|
+
Dry::Monads::Success(branch)
|
|
131
|
+
else
|
|
132
|
+
Dry::Monads::Failure({path: path, branch: branch, reason: "git switch refused", stderr: result.failure[:stderr]})
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Handle an unborn (empty) local clone. If the remote has no
|
|
137
|
+
# branches, the repo is already a valid empty clone — return
|
|
138
|
+
# Success(:empty) with no mutation. If the remote has gained
|
|
139
|
+
# commits, fetch and fast-forward the unborn branch into them.
|
|
140
|
+
#
|
|
141
|
+
# `git ls-remote --heads origin` is the authoritative
|
|
142
|
+
# empty-vs-error discriminator: exit 0 + empty stdout means the
|
|
143
|
+
# remote truly has no branches; exit 0 + output means it has
|
|
144
|
+
# commits; non-zero exit means a real network/probe error.
|
|
145
|
+
def sync_empty(path)
|
|
146
|
+
ls = Shell.run("git", "ls-remote", "--heads", "origin", chdir: path)
|
|
147
|
+
return ls if ls.failure?
|
|
148
|
+
|
|
149
|
+
return Dry::Monads::Success(:empty) if ls.success.strip.empty?
|
|
150
|
+
|
|
151
|
+
fetch_result = fetch(path)
|
|
152
|
+
return fetch_result if fetch_result.failure?
|
|
153
|
+
|
|
154
|
+
branch_result = default_branch(path)
|
|
155
|
+
return branch_result if branch_result.failure?
|
|
156
|
+
|
|
157
|
+
upstream = "origin/#{branch_result.success}"
|
|
158
|
+
merge = Shell.run("git", "merge", "--ff-only", upstream, chdir: path)
|
|
159
|
+
if merge.success?
|
|
160
|
+
Dry::Monads::Success(:fast_forwarded)
|
|
161
|
+
else
|
|
162
|
+
Dry::Monads::Failure({path: path, reason: "ff merge into unborn branch failed", stderr: merge.failure[:stderr]})
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# `git symbolic-ref --short refs/remotes/origin/HEAD` returns
|
|
169
|
+
# `origin/<branch>` (the short form of `refs/remotes/origin/<branch>`);
|
|
170
|
+
# callers want the bare branch name. Returns nil when origin/HEAD
|
|
171
|
+
# is unset.
|
|
172
|
+
def read_origin_head(path)
|
|
173
|
+
result = Shell.run("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD", chdir: path)
|
|
174
|
+
return nil unless result.success?
|
|
175
|
+
line = result.success.strip
|
|
176
|
+
return nil if line.empty?
|
|
177
|
+
line.sub(%r{\Aorigin/}, "")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Parse `git status --porcelain=v2 --branch --untracked-files=normal`.
|
|
181
|
+
# v2 grammar (from the git-status man page):
|
|
182
|
+
# # branch.oid <commit-ish> | (initial)
|
|
183
|
+
# # branch.head <name> | (detached)
|
|
184
|
+
# # branch.upstream <upstream-branch>
|
|
185
|
+
# # branch.ab +<ahead> -<behind>
|
|
186
|
+
# 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
|
|
187
|
+
# 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path><TAB><origPath>
|
|
188
|
+
# u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
|
|
189
|
+
# ? <path>
|
|
190
|
+
# ! <path>
|
|
191
|
+
def parse_porcelain_v2(output)
|
|
192
|
+
branch = nil
|
|
193
|
+
upstream = nil
|
|
194
|
+
ahead = 0
|
|
195
|
+
behind = 0
|
|
196
|
+
detached = false
|
|
197
|
+
unborn = false
|
|
198
|
+
entries = []
|
|
199
|
+
|
|
200
|
+
output.each_line do |raw|
|
|
201
|
+
line = raw.chomp
|
|
202
|
+
next if line.empty?
|
|
203
|
+
case line
|
|
204
|
+
when /\A# branch\.oid (.+)/
|
|
205
|
+
unborn = (Regexp.last_match(1) == "(initial)")
|
|
206
|
+
when /\A# branch\.head (.+)/
|
|
207
|
+
branch = Regexp.last_match(1)
|
|
208
|
+
detached = (branch == "(detached)")
|
|
209
|
+
when /\A# branch\.upstream (.+)/
|
|
210
|
+
upstream = Regexp.last_match(1)
|
|
211
|
+
when /\A# branch\.ab \+(\d+) -(\d+)/
|
|
212
|
+
ahead = Regexp.last_match(1).to_i
|
|
213
|
+
behind = Regexp.last_match(2).to_i
|
|
214
|
+
when /\A[12u?!]/
|
|
215
|
+
entries << line
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
Status.new(
|
|
220
|
+
clean: entries.empty?,
|
|
221
|
+
branch: branch,
|
|
222
|
+
upstream: upstream,
|
|
223
|
+
ahead: ahead,
|
|
224
|
+
behind: behind,
|
|
225
|
+
detached: detached,
|
|
226
|
+
entries: entries,
|
|
227
|
+
unborn: unborn
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpaceArchitect::Pristine
|
|
4
|
+
module SCM
|
|
5
|
+
# Value object produced by parsing `git status --porcelain=v2
|
|
6
|
+
# --branch --untracked-files=normal`. v2 is mandatory (per
|
|
7
|
+
# AGENTS.md gotcha) so submodule state and rename detection are
|
|
8
|
+
# stable.
|
|
9
|
+
#
|
|
10
|
+
# A working tree is "clean" iff the only porcelain-v2 lines are the
|
|
11
|
+
# `# branch.*` header lines. Any `1`/`2`/`u`/`?`/`!` line is dirty.
|
|
12
|
+
Status = Data.define(:clean, :branch, :upstream, :ahead, :behind, :detached, :entries, :unborn) do
|
|
13
|
+
def initialize(clean:, branch: nil, upstream: nil, ahead: 0, behind: 0, detached: false, entries: [], unborn: false)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clean? = clean
|
|
18
|
+
|
|
19
|
+
def detached? = detached
|
|
20
|
+
|
|
21
|
+
def unborn? = unborn
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "async"
|
|
5
|
+
require "dry/monads"
|
|
6
|
+
|
|
7
|
+
module SpaceArchitect::Pristine
|
|
8
|
+
# Thin Open3.capture3 wrapper that:
|
|
9
|
+
# * requires an ambient Async::Task (so subprocess I/O flows through
|
|
10
|
+
# Ruby's Fiber scheduler → kqueue on macOS and is non-blocking);
|
|
11
|
+
# * returns a Dry::Monads::Result — Success(stdout) on zero exit,
|
|
12
|
+
# Failure({argv:, stderr:, status:}) otherwise.
|
|
13
|
+
#
|
|
14
|
+
# Per AGENTS.md: no `async-process`. Per PRD §2: boundaries return
|
|
15
|
+
# Result, exceptions are for programmer error only.
|
|
16
|
+
class Shell
|
|
17
|
+
extend Dry::Monads[:result]
|
|
18
|
+
|
|
19
|
+
@run_count = 0
|
|
20
|
+
@saved_roe = nil
|
|
21
|
+
|
|
22
|
+
def self.run(*argv, chdir: nil, env: nil)
|
|
23
|
+
raise ArgumentError, "Shell.run requires at least argv" if argv.empty?
|
|
24
|
+
raise "Shell.run must be called inside an ambient Async::Task" unless Async::Task.current?
|
|
25
|
+
|
|
26
|
+
full_env = env ? ENV.to_h.merge(env.transform_keys(&:to_s)) : nil
|
|
27
|
+
opts = {}
|
|
28
|
+
opts[:chdir] = chdir if chdir
|
|
29
|
+
# Open3.capture3: env is a leading hash positional arg, not a kwarg.
|
|
30
|
+
#
|
|
31
|
+
# Open3.capture3 spawns the child with two internal reader
|
|
32
|
+
# threads (one for stdout, one for stderr; see
|
|
33
|
+
# `rubylibdir/open3.rb` ~L644: `out_reader = Thread.new { o.read }`
|
|
34
|
+
# / `err_reader = Thread.new { e.read }`). When the `popen3`
|
|
35
|
+
# block exits via exception (e.g. the user ^C'd mid-Shell.run
|
|
36
|
+
# via SIGINT), `popen_run`'s ensure closes the read pipes from
|
|
37
|
+
# the main thread while those reader threads are still inside
|
|
38
|
+
# `o.read` / `e.read`. The mid-read close races with the reader
|
|
39
|
+
# and raises `IOError: stream closed in another thread` in the
|
|
40
|
+
# reader thread. With the default `Thread.report_on_exception
|
|
41
|
+
# = true` (since Ruby 2.5), Ruby prints a multi-line backtrace
|
|
42
|
+
# to stderr for that orphaned thread — exactly the noise
|
|
43
|
+
# Slice 6 G3 silences.
|
|
44
|
+
#
|
|
45
|
+
# We bracket the `Open3.capture3` call with a save/restore of
|
|
46
|
+
# `Thread.report_on_exception = false`. This is targeted
|
|
47
|
+
# because, at this code site, the ONLY threads in flight are:
|
|
48
|
+
# * the main thread (this method's caller);
|
|
49
|
+
# * Async's internal `io_select` thread
|
|
50
|
+
# (`async/lib/async/scheduler.rb` L425) — which silences
|
|
51
|
+
# its own report (`Thread.current.report_on_exception =
|
|
52
|
+
# false` on that thread, not globally);
|
|
53
|
+
# * the Open3 reader threads (the source of the noise).
|
|
54
|
+
# `lib/` has zero `Thread.new` calls; `dry-cli`, `dry-monads`,
|
|
55
|
+
# `dry-validation`, `dry-struct`, `dry-types`, `dry-schema`,
|
|
56
|
+
# `xdg` have none either (verified Slice 6 PHASE 0). So we are
|
|
57
|
+
# NOT hiding any app-owned worker-thread crashes — the only
|
|
58
|
+
# thread that can raise here is the Open3 reader thread, and
|
|
59
|
+
# the only thing it can raise is the IOError we explicitly
|
|
60
|
+
# want to silence. The original value is restored in `ensure`
|
|
61
|
+
# so we never leak the suppression past this call.
|
|
62
|
+
# Refcount the active Shell.run calls so the global flag is suppressed
|
|
63
|
+
# for the entire overlapping window, not just per-fiber. On 0→1: capture
|
|
64
|
+
# original and set false. On 1→0 (in ensure): restore the original.
|
|
65
|
+
# Safe without a Mutex: the reactor is single-threaded; fibers only yield
|
|
66
|
+
# at Open3.capture3's thread-join, never between these plain assignments.
|
|
67
|
+
if @run_count == 0
|
|
68
|
+
@saved_roe = Thread.report_on_exception
|
|
69
|
+
Thread.report_on_exception = false
|
|
70
|
+
end
|
|
71
|
+
@run_count += 1
|
|
72
|
+
begin
|
|
73
|
+
stdout, stderr, status = if full_env
|
|
74
|
+
Open3.capture3(full_env, *argv, **opts)
|
|
75
|
+
else
|
|
76
|
+
Open3.capture3(*argv, **opts)
|
|
77
|
+
end
|
|
78
|
+
ensure
|
|
79
|
+
@run_count -= 1
|
|
80
|
+
Thread.report_on_exception = @saved_roe if @run_count == 0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if status.success?
|
|
84
|
+
Success(stdout)
|
|
85
|
+
else
|
|
86
|
+
Failure({argv: argv, stderr: stderr, status: status.exitstatus})
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module SpaceArchitect::Pristine
|
|
6
|
+
module State
|
|
7
|
+
# Advisory process-level lock on a sidecar file derived from the
|
|
8
|
+
# state file path. Serializes overlapping `sync` runs so the
|
|
9
|
+
# later run bails cleanly rather than clobbering the in-flight
|
|
10
|
+
# run's state with a last-writer-wins atomic rename (CF10).
|
|
11
|
+
#
|
|
12
|
+
# The lockfile is a persistent zero-byte sentinel — never unlinked.
|
|
13
|
+
# Deleting a flock'd file while another fd holds it creates a race
|
|
14
|
+
# where both processes think they hold the lock on different inodes.
|
|
15
|
+
#
|
|
16
|
+
# `LOCK_NB` is for daemon safety, not reactor yielding: a blocking
|
|
17
|
+
# `LOCK_EX` would wedge a launchd tick forever behind a hung run.
|
|
18
|
+
# `flock` and the file I/O it guards are ordinary blocking syscalls
|
|
19
|
+
# with no Async scheduler hook — fine here, they're sub-millisecond
|
|
20
|
+
# on a local state dir, same as the rest of `State::Store`.
|
|
21
|
+
class Lock
|
|
22
|
+
NOT_ACQUIRED = :not_acquired
|
|
23
|
+
|
|
24
|
+
# Returns the sidecar lockfile path for a given state_file path.
|
|
25
|
+
def self.path_for(state_file)
|
|
26
|
+
"#{state_file}.lock"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Acquires an exclusive non-blocking advisory lock on the sidecar
|
|
30
|
+
# lockfile. Creates the file (and its parent directory) if missing.
|
|
31
|
+
#
|
|
32
|
+
# Yields to the block and returns its value when the lock is
|
|
33
|
+
# acquired; releases the lock in an `ensure` so it is freed on
|
|
34
|
+
# normal return, explicit `return`, AND any escaping exception
|
|
35
|
+
# (including Interrupt / SignalException).
|
|
36
|
+
#
|
|
37
|
+
# Returns `NOT_ACQUIRED` *without* yielding when another process
|
|
38
|
+
# already holds the lock. The caller decides what to do (e.g.
|
|
39
|
+
# bail cleanly with a Success).
|
|
40
|
+
def self.acquire(state_file)
|
|
41
|
+
lock_path = path_for(state_file)
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(lock_path))
|
|
43
|
+
f = File.open(lock_path, File::RDWR | File::CREAT)
|
|
44
|
+
# Everything that can raise — `flock` itself can (EINTR on a
|
|
45
|
+
# signal, ENOLCK on some filesystems) — runs inside the `begin`,
|
|
46
|
+
# so the `ensure` always closes the fd (no leak). Closing the fd
|
|
47
|
+
# releases the lock (it lives on the open file description), so
|
|
48
|
+
# no explicit LOCK_UN is needed — and `close` can't be skipped
|
|
49
|
+
# by a raising unlock.
|
|
50
|
+
begin
|
|
51
|
+
return NOT_ACQUIRED unless f.flock(File::LOCK_EX | File::LOCK_NB)
|
|
52
|
+
yield
|
|
53
|
+
ensure
|
|
54
|
+
f.close
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
require "dry/monads"
|
|
7
|
+
|
|
8
|
+
module SpaceArchitect::Pristine
|
|
9
|
+
module State
|
|
10
|
+
# Machine-managed state at $XDG_STATE_HOME/repo-tender/state.yaml.
|
|
11
|
+
# Never hand-edited (per PRD §3.2). Per-repo + per-org records with
|
|
12
|
+
# a fixed status enum; the store validates the enum and timestamp
|
|
13
|
+
# format on write.
|
|
14
|
+
class Store
|
|
15
|
+
extend Dry::Monads[:result]
|
|
16
|
+
|
|
17
|
+
STATUSES = %w[clean dirty diverged detached wrong_branch missing error].freeze
|
|
18
|
+
|
|
19
|
+
Repo = Data.define(:default_branch, :last_fetch_at, :last_synced_at, :status, :last_error) do
|
|
20
|
+
def initialize(default_branch: nil, last_fetch_at: nil, last_synced_at: nil, status: nil, last_error: nil)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h_compact
|
|
25
|
+
{
|
|
26
|
+
"default_branch" => default_branch,
|
|
27
|
+
"last_fetch_at" => format_time(last_fetch_at),
|
|
28
|
+
"last_synced_at" => format_time(last_synced_at),
|
|
29
|
+
"status" => status,
|
|
30
|
+
"last_error" => last_error
|
|
31
|
+
}.compact
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def format_time(t)
|
|
37
|
+
t.respond_to?(:iso8601) ? t.iso8601 : t
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Org = Data.define(:last_listed_at, :repo_count, :last_error) do
|
|
42
|
+
def initialize(last_listed_at: nil, repo_count: 0, last_error: nil)
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h_compact
|
|
47
|
+
{
|
|
48
|
+
"last_listed_at" => format_time(last_listed_at),
|
|
49
|
+
"repo_count" => repo_count,
|
|
50
|
+
"last_error" => last_error
|
|
51
|
+
}.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# `last_listed_at` may arrive as a Time (fresh from the
|
|
57
|
+
# engine) or as a String (round-tripped from YAML, since
|
|
58
|
+
# `to_h_compact` always emits the iso8601 form on
|
|
59
|
+
# write). Both forms are written as the same string on
|
|
60
|
+
# the next emit; the helper accepts either.
|
|
61
|
+
def format_time(t)
|
|
62
|
+
return nil if t.nil?
|
|
63
|
+
t.respond_to?(:iso8601) ? t.iso8601 : t
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.load(path)
|
|
68
|
+
raw = read_yaml(path)
|
|
69
|
+
Success(build_state(raw))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.write(path, state)
|
|
73
|
+
validation = validate(state)
|
|
74
|
+
return validation if validation.failure?
|
|
75
|
+
|
|
76
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
77
|
+
tmp = "#{path}.tmp.#{Process.pid}"
|
|
78
|
+
begin
|
|
79
|
+
File.write(tmp, emit(state))
|
|
80
|
+
File.rename(tmp, path)
|
|
81
|
+
ensure
|
|
82
|
+
File.delete(tmp) if File.exist?(tmp)
|
|
83
|
+
end
|
|
84
|
+
Success(state)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.validate(state)
|
|
88
|
+
state.repos.each do |key, repo|
|
|
89
|
+
unless STATUSES.include?(repo.status)
|
|
90
|
+
return Failure({repos: {key => {status: ["must be one of: #{STATUSES.join(", ")}"]}}})
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
Success(state)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.read_yaml(path)
|
|
97
|
+
return {} unless File.exist?(path)
|
|
98
|
+
# Time class permitted because state.yaml stores ISO8601
|
|
99
|
+
# timestamps; Psych will deserialize them as Time when the
|
|
100
|
+
# scalar is tagged. No other arbitrary classes allowed.
|
|
101
|
+
YAML.safe_load_file(path, permitted_classes: [Symbol, Time], aliases: false) || {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.build_state(raw)
|
|
105
|
+
repos = (raw["repos"] || {}).each_with_object({}) do |(key, attrs), acc|
|
|
106
|
+
acc[key] = Repo.new(
|
|
107
|
+
default_branch: attrs["default_branch"],
|
|
108
|
+
last_fetch_at: attrs["last_fetch_at"],
|
|
109
|
+
last_synced_at: attrs["last_synced_at"],
|
|
110
|
+
status: attrs["status"],
|
|
111
|
+
last_error: attrs["last_error"]
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
orgs = (raw["orgs"] || {}).each_with_object({}) do |(key, attrs), acc|
|
|
115
|
+
acc[key] = Org.new(
|
|
116
|
+
last_listed_at: attrs["last_listed_at"],
|
|
117
|
+
repo_count: attrs["repo_count"] || 0,
|
|
118
|
+
last_error: attrs["last_error"]
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
State.new(repos: repos, orgs: orgs)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# State value object — top-level container.
|
|
125
|
+
State = Data.define(:repos, :orgs) do
|
|
126
|
+
def initialize(repos: {}, orgs: {})
|
|
127
|
+
super
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.emit(state)
|
|
132
|
+
payload = {
|
|
133
|
+
"repos" => state.repos.each_with_object({}) { |(k, v), acc| acc[k] = v.to_h_compact },
|
|
134
|
+
"orgs" => state.orgs.each_with_object({}) { |(k, v), acc| acc[k] = v.to_h_compact }
|
|
135
|
+
}
|
|
136
|
+
YAML.dump(payload, line_width: -1)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|