space-architect 8.0.0 → 9.0.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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +53 -243
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +20 -7
- data/lib/space_architect/cli.rb +9 -14
- data/lib/space_architect/session_sync/bin_path.rb +1 -1
- data/lib/space_architect/session_sync/cursor.rb +1 -1
- data/lib/space_architect/session_sync/plist.rb +1 -1
- data/lib/{space_core → space_architect}/version.rb +2 -2
- data/lib/space_architect.rb +1 -1
- data/skill/architect/SKILL.md +3 -2
- data/skill/architect-vocabulary/SKILL.md +2 -2
- metadata +18 -177
- data/exe/space +0 -13
- data/exe/src +0 -13
- data/lib/space_architect/cli/space.rb +0 -29
- data/lib/space_architect/cli/src.rb +0 -38
- data/lib/space_core/atomic_write.rb +0 -21
- data/lib/space_core/cli/base_command.rb +0 -19
- data/lib/space_core/cli/build.rb +0 -27
- data/lib/space_core/cli/config.rb +0 -49
- data/lib/space_core/cli/current.rb +0 -16
- data/lib/space_core/cli/help.rb +0 -195
- data/lib/space_core/cli/helpers.rb +0 -115
- data/lib/space_core/cli/init.rb +0 -29
- data/lib/space_core/cli/list.rb +0 -24
- data/lib/space_core/cli/loop_status.rb +0 -47
- data/lib/space_core/cli/new.rb +0 -38
- data/lib/space_core/cli/pack.rb +0 -29
- data/lib/space_core/cli/path.rb +0 -16
- data/lib/space_core/cli/repeatable_options.rb +0 -84
- data/lib/space_core/cli/repo.rb +0 -76
- data/lib/space_core/cli/run.rb +0 -44
- data/lib/space_core/cli/shell.rb +0 -125
- data/lib/space_core/cli/show.rb +0 -21
- data/lib/space_core/cli/status.rb +0 -74
- data/lib/space_core/cli/use.rb +0 -17
- data/lib/space_core/cli.rb +0 -177
- data/lib/space_core/commands.rb +0 -51
- data/lib/space_core/config.rb +0 -152
- data/lib/space_core/errors.rb +0 -14
- data/lib/space_core/git_client.rb +0 -49
- data/lib/space_core/mise_client.rb +0 -37
- data/lib/space_core/oci_builder.rb +0 -56
- data/lib/space_core/oci_packer.rb +0 -103
- data/lib/space_core/oci_runner.rb +0 -89
- data/lib/space_core/paths.rb +0 -58
- data/lib/space_core/repo_reference.rb +0 -19
- data/lib/space_core/repo_resolver.rb +0 -167
- data/lib/space_core/shell_integration.rb +0 -438
- data/lib/space_core/slugger.rb +0 -16
- data/lib/space_core/space.rb +0 -174
- data/lib/space_core/space_store.rb +0 -319
- data/lib/space_core/state.rb +0 -86
- data/lib/space_core/templates/oci/dockerfile.erb +0 -73
- data/lib/space_core/templates/oci/dockerignore.erb +0 -17
- data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
- data/lib/space_core/terminal.rb +0 -161
- data/lib/space_core/warnings.rb +0 -13
- data/lib/space_core/xdg.rb +0 -33
- data/lib/space_core.rb +0 -26
- data/lib/space_src/cli/clone.rb +0 -54
- data/lib/space_src/cli/config.rb +0 -65
- data/lib/space_src/cli/daemon.rb +0 -362
- data/lib/space_src/cli/options.rb +0 -21
- data/lib/space_src/cli/org.rb +0 -199
- data/lib/space_src/cli/repo.rb +0 -169
- data/lib/space_src/cli/shell.rb +0 -121
- data/lib/space_src/cli/status.rb +0 -75
- data/lib/space_src/cli/sync.rb +0 -161
- data/lib/space_src/cli.rb +0 -159
- data/lib/space_src/cloner.rb +0 -77
- data/lib/space_src/config/contract.rb +0 -54
- data/lib/space_src/config/duration.rb +0 -79
- data/lib/space_src/config/model.rb +0 -49
- data/lib/space_src/config/store.rb +0 -156
- data/lib/space_src/forge/client.rb +0 -31
- data/lib/space_src/forge/github.rb +0 -98
- data/lib/space_src/launchd/agent.rb +0 -195
- data/lib/space_src/launchd/plist.rb +0 -129
- data/lib/space_src/log_rotator.rb +0 -46
- data/lib/space_src/migration.rb +0 -43
- data/lib/space_src/nav.rb +0 -99
- data/lib/space_src/paths.rb +0 -72
- data/lib/space_src/scm/client.rb +0 -87
- data/lib/space_src/scm/git.rb +0 -232
- data/lib/space_src/scm/status.rb +0 -24
- data/lib/space_src/shell.rb +0 -90
- data/lib/space_src/shell_integration.rb +0 -321
- data/lib/space_src/state/lock.rb +0 -59
- data/lib/space_src/state/store.rb +0 -140
- data/lib/space_src/sync/engine.rb +0 -503
- data/lib/space_src/sync/repo_plan.rb +0 -215
- data/lib/space_src/sync/report.rb +0 -18
- data/lib/space_src/ui/interactive_reporter.rb +0 -280
- data/lib/space_src/ui/json_reporter.rb +0 -39
- data/lib/space_src/ui/mode.rb +0 -68
- data/lib/space_src/ui/plain_reporter.rb +0 -53
- data/lib/space_src/ui/reporter.rb +0 -48
- data/lib/space_src/version.rb +0 -7
- data/lib/space_src.rb +0 -38
data/lib/space_src/migration.rb
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
|
|
5
|
-
module Space::Src
|
|
6
|
-
# One-shot data-preserving migration from the `repo-tender` identity
|
|
7
|
-
# to `space-src`. Invoked from CLI.run before dispatch on every run;
|
|
8
|
-
# all operations are idempotent so repeated invocations are safe.
|
|
9
|
-
class Migration
|
|
10
|
-
OLD_APP_NAME = "repo-tender"
|
|
11
|
-
OLD_LABEL = "io.github.jetpks.repo-tender.sync"
|
|
12
|
-
|
|
13
|
-
# Move old-identity XDG dirs to new-identity locations if the old
|
|
14
|
-
# ones exist and the new ones do not (no-clobber — no data loss).
|
|
15
|
-
# Print a one-line notice to `err` only when something is actually
|
|
16
|
-
# moved. Also warn if the old-label launchd plist is still present
|
|
17
|
-
# so the user knows to run `src daemon install`.
|
|
18
|
-
def self.run(paths:, err:)
|
|
19
|
-
moved = false
|
|
20
|
-
|
|
21
|
-
old_config = File.join(paths.config_home, OLD_APP_NAME)
|
|
22
|
-
new_config = paths.config_dir
|
|
23
|
-
if File.directory?(old_config) && !File.exist?(new_config)
|
|
24
|
-
FileUtils.mv(old_config, new_config)
|
|
25
|
-
moved = true
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
old_state = File.join(paths.state_home, OLD_APP_NAME)
|
|
29
|
-
new_state = paths.state_dir
|
|
30
|
-
if File.directory?(old_state) && !File.exist?(new_state)
|
|
31
|
-
FileUtils.mv(old_state, new_state)
|
|
32
|
-
moved = true
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
err.puts "space-src: migrated config/state from #{OLD_APP_NAME}" if moved
|
|
36
|
-
|
|
37
|
-
old_plist = File.join(paths.launch_agents_dir, "#{OLD_LABEL}.plist")
|
|
38
|
-
if File.exist?(old_plist)
|
|
39
|
-
err.puts "space-src: stale launchd agent found (#{OLD_LABEL}); run `src daemon install` to upgrade"
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
data/lib/space_src/nav.rb
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Space::Src
|
|
4
|
-
# Fuzzy navigator over on-disk source checkouts.
|
|
5
|
-
#
|
|
6
|
-
# Checkouts live at base_dir/<host>/<owner>/<name> (depth-3 dirs).
|
|
7
|
-
# Matching is a case-insensitive SUBSEQUENCE against "owner/name" —
|
|
8
|
-
# host is excluded from the match but is part of the resolved path.
|
|
9
|
-
#
|
|
10
|
-
# Ranking is fzf-inspired: contiguity bonus, word-boundary bonus,
|
|
11
|
-
# earliness (earlier first match wins). Ties break by target string
|
|
12
|
-
# asc then host asc — deterministic total order.
|
|
13
|
-
module Nav
|
|
14
|
-
# Enumerate all depth-3 directories under base_dir.
|
|
15
|
-
# Returns array of hashes: {host:, owner:, name:, target:, path:}.
|
|
16
|
-
def self.scan(base_dir)
|
|
17
|
-
pattern = File.join(base_dir, "*", "*", "*")
|
|
18
|
-
prefix = base_dir.chomp("/") + "/"
|
|
19
|
-
# paths:exempt - layout intent (depth-3 checkout scan); space_src is deliberately independent of space_core and must not require it
|
|
20
|
-
Dir.glob(pattern).filter_map do |path|
|
|
21
|
-
next unless File.directory?(path)
|
|
22
|
-
relative = path.delete_prefix(prefix)
|
|
23
|
-
parts = relative.split("/")
|
|
24
|
-
next unless parts.length == 3
|
|
25
|
-
host, owner, name = parts
|
|
26
|
-
{host:, owner:, name:, target: "#{owner}/#{name}", path:}
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Pure: find the leftmost match positions of query chars (case-insensitive)
|
|
31
|
-
# as a subsequence into target. Returns an array of integer indices, or nil
|
|
32
|
-
# if the query is not a subsequence of the target.
|
|
33
|
-
def self.match_positions(query, target)
|
|
34
|
-
q = query.downcase
|
|
35
|
-
t = target.downcase
|
|
36
|
-
positions = []
|
|
37
|
-
qi = 0
|
|
38
|
-
t.each_char.with_index do |c, i|
|
|
39
|
-
if c == q[qi]
|
|
40
|
-
positions << i
|
|
41
|
-
qi += 1
|
|
42
|
-
return positions if qi == q.length
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
nil
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Pure: compute a score for a set of match positions within target_lower.
|
|
49
|
-
# Higher score = better match.
|
|
50
|
-
# contiguity: each consecutive pair of matched indices scores +10
|
|
51
|
-
# word boundary: each position at start of string or right after
|
|
52
|
-
# '/', '-', '_' scores +5
|
|
53
|
-
# earliness: subtract the first matched position (earlier = higher score)
|
|
54
|
-
def self.score_match(positions, target_lower)
|
|
55
|
-
contiguity = positions.each_cons(2).count { |a, b| b == a + 1 } * 10
|
|
56
|
-
boundary = positions.count do |p|
|
|
57
|
-
p == 0 || "/\\-_".include?(target_lower[p - 1])
|
|
58
|
-
end * 5
|
|
59
|
-
earliness = -positions.first
|
|
60
|
-
contiguity + boundary + earliness
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Pure: match and rank a list of entry hashes against query.
|
|
64
|
-
# Returns entries annotated with :score, sorted best-first.
|
|
65
|
-
# Tie-break: target asc, then host asc.
|
|
66
|
-
def self.rank(entries, query)
|
|
67
|
-
scored = entries.filter_map do |e|
|
|
68
|
-
t = e[:target].downcase
|
|
69
|
-
positions = match_positions(query, t)
|
|
70
|
-
next unless positions
|
|
71
|
-
score = score_match(positions, t)
|
|
72
|
-
e.merge(score:)
|
|
73
|
-
end
|
|
74
|
-
scored.sort_by { |e| [-e[:score], e[:target], e[:host]] }
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Cd-contract executor. Scans base_dir for checkouts, fuzzy-matches
|
|
78
|
-
# query, applies the 0/1/many contract:
|
|
79
|
-
# - exactly one match → absolute path on last stdout line, returns 0
|
|
80
|
-
# - zero matches → message on stderr, returns 1
|
|
81
|
-
# - multiple matches → ranked candidates on stdout, returns 1
|
|
82
|
-
def self.dispatch(query, stdout, stderr, base_dir)
|
|
83
|
-
entries = scan(base_dir)
|
|
84
|
-
matches = rank(entries, query)
|
|
85
|
-
|
|
86
|
-
case matches.length
|
|
87
|
-
when 0
|
|
88
|
-
stderr.puts "src: no match for '#{query}'"
|
|
89
|
-
1
|
|
90
|
-
when 1
|
|
91
|
-
stdout.puts matches.first[:path]
|
|
92
|
-
0
|
|
93
|
-
else
|
|
94
|
-
matches.each { |m| stdout.puts "#{m[:host]}/#{m[:target]}" }
|
|
95
|
-
1
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
data/lib/space_src/paths.rb
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "xdg"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
|
|
6
|
-
module Space::Src
|
|
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 = "space-src"
|
|
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
|
data/lib/space_src/scm/client.rb
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry/monads"
|
|
4
|
-
|
|
5
|
-
module Space::Src
|
|
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
|
data/lib/space_src/scm/git.rb
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "time"
|
|
5
|
-
require "space_src/scm/client"
|
|
6
|
-
require "space_src/scm/status"
|
|
7
|
-
require "space_src/shell"
|
|
8
|
-
|
|
9
|
-
module Space::Src
|
|
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
|
data/lib/space_src/scm/status.rb
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Space::Src
|
|
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
|
data/lib/space_src/shell.rb
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "open3"
|
|
4
|
-
require "async"
|
|
5
|
-
require "dry/monads"
|
|
6
|
-
|
|
7
|
-
module Space::Src
|
|
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
|