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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +53 -243
  4. data/lib/space_architect/bug_report.rb +2 -1
  5. data/lib/space_architect/cli/architect.rb +20 -7
  6. data/lib/space_architect/cli.rb +9 -14
  7. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  8. data/lib/space_architect/session_sync/cursor.rb +1 -1
  9. data/lib/space_architect/session_sync/plist.rb +1 -1
  10. data/lib/{space_core → space_architect}/version.rb +2 -2
  11. data/lib/space_architect.rb +1 -1
  12. data/skill/architect/SKILL.md +3 -2
  13. data/skill/architect-vocabulary/SKILL.md +2 -2
  14. metadata +18 -177
  15. data/exe/space +0 -13
  16. data/exe/src +0 -13
  17. data/lib/space_architect/cli/space.rb +0 -29
  18. data/lib/space_architect/cli/src.rb +0 -38
  19. data/lib/space_core/atomic_write.rb +0 -21
  20. data/lib/space_core/cli/base_command.rb +0 -19
  21. data/lib/space_core/cli/build.rb +0 -27
  22. data/lib/space_core/cli/config.rb +0 -49
  23. data/lib/space_core/cli/current.rb +0 -16
  24. data/lib/space_core/cli/help.rb +0 -195
  25. data/lib/space_core/cli/helpers.rb +0 -115
  26. data/lib/space_core/cli/init.rb +0 -29
  27. data/lib/space_core/cli/list.rb +0 -24
  28. data/lib/space_core/cli/loop_status.rb +0 -47
  29. data/lib/space_core/cli/new.rb +0 -38
  30. data/lib/space_core/cli/pack.rb +0 -29
  31. data/lib/space_core/cli/path.rb +0 -16
  32. data/lib/space_core/cli/repeatable_options.rb +0 -84
  33. data/lib/space_core/cli/repo.rb +0 -76
  34. data/lib/space_core/cli/run.rb +0 -44
  35. data/lib/space_core/cli/shell.rb +0 -125
  36. data/lib/space_core/cli/show.rb +0 -21
  37. data/lib/space_core/cli/status.rb +0 -74
  38. data/lib/space_core/cli/use.rb +0 -17
  39. data/lib/space_core/cli.rb +0 -177
  40. data/lib/space_core/commands.rb +0 -51
  41. data/lib/space_core/config.rb +0 -152
  42. data/lib/space_core/errors.rb +0 -14
  43. data/lib/space_core/git_client.rb +0 -49
  44. data/lib/space_core/mise_client.rb +0 -37
  45. data/lib/space_core/oci_builder.rb +0 -56
  46. data/lib/space_core/oci_packer.rb +0 -103
  47. data/lib/space_core/oci_runner.rb +0 -89
  48. data/lib/space_core/paths.rb +0 -58
  49. data/lib/space_core/repo_reference.rb +0 -19
  50. data/lib/space_core/repo_resolver.rb +0 -167
  51. data/lib/space_core/shell_integration.rb +0 -438
  52. data/lib/space_core/slugger.rb +0 -16
  53. data/lib/space_core/space.rb +0 -174
  54. data/lib/space_core/space_store.rb +0 -319
  55. data/lib/space_core/state.rb +0 -86
  56. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  57. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  58. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  59. data/lib/space_core/terminal.rb +0 -161
  60. data/lib/space_core/warnings.rb +0 -13
  61. data/lib/space_core/xdg.rb +0 -33
  62. data/lib/space_core.rb +0 -26
  63. data/lib/space_src/cli/clone.rb +0 -54
  64. data/lib/space_src/cli/config.rb +0 -65
  65. data/lib/space_src/cli/daemon.rb +0 -362
  66. data/lib/space_src/cli/options.rb +0 -21
  67. data/lib/space_src/cli/org.rb +0 -199
  68. data/lib/space_src/cli/repo.rb +0 -169
  69. data/lib/space_src/cli/shell.rb +0 -121
  70. data/lib/space_src/cli/status.rb +0 -75
  71. data/lib/space_src/cli/sync.rb +0 -161
  72. data/lib/space_src/cli.rb +0 -159
  73. data/lib/space_src/cloner.rb +0 -77
  74. data/lib/space_src/config/contract.rb +0 -54
  75. data/lib/space_src/config/duration.rb +0 -79
  76. data/lib/space_src/config/model.rb +0 -49
  77. data/lib/space_src/config/store.rb +0 -156
  78. data/lib/space_src/forge/client.rb +0 -31
  79. data/lib/space_src/forge/github.rb +0 -98
  80. data/lib/space_src/launchd/agent.rb +0 -195
  81. data/lib/space_src/launchd/plist.rb +0 -129
  82. data/lib/space_src/log_rotator.rb +0 -46
  83. data/lib/space_src/migration.rb +0 -43
  84. data/lib/space_src/nav.rb +0 -99
  85. data/lib/space_src/paths.rb +0 -72
  86. data/lib/space_src/scm/client.rb +0 -87
  87. data/lib/space_src/scm/git.rb +0 -232
  88. data/lib/space_src/scm/status.rb +0 -24
  89. data/lib/space_src/shell.rb +0 -90
  90. data/lib/space_src/shell_integration.rb +0 -321
  91. data/lib/space_src/state/lock.rb +0 -59
  92. data/lib/space_src/state/store.rb +0 -140
  93. data/lib/space_src/sync/engine.rb +0 -503
  94. data/lib/space_src/sync/repo_plan.rb +0 -215
  95. data/lib/space_src/sync/report.rb +0 -18
  96. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  97. data/lib/space_src/ui/json_reporter.rb +0 -39
  98. data/lib/space_src/ui/mode.rb +0 -68
  99. data/lib/space_src/ui/plain_reporter.rb +0 -53
  100. data/lib/space_src/ui/reporter.rb +0 -48
  101. data/lib/space_src/version.rb +0 -7
  102. data/lib/space_src.rb +0 -38
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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