grubber-twin 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7c9dc2a33a083ec7264d3c7ffe99c308aaf614fa8afcd67a6725e28e056235d8
4
+ data.tar.gz: 1aeeaa68cffcc6f1091d3a06fb7747c3b209dfbe5c6846e930540859426859c2
5
+ SHA512:
6
+ metadata.gz: 4c58cc776b775987cd0ab2a761b580152292161d01817ff7e391bb8949ecd9c0cd70532ea1d6e36cade1c52bfd41af8a0c56d35f760e006ef8edc0e83a2bbd46
7
+ data.tar.gz: bc1b072245d428416c438658666af5bed697048ad9ba76c6c2ea618711634576c4de509b2ba3ed00bd3ad19f301c1b8649b2c0b3b39ccd902cb4209c87657556
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,138 @@
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ ```
6
+ sync-files (.md)
7
+
8
+
9
+ grubber parse Markdown, extract YAML blocks, merge frontmatter
10
+
11
+
12
+ Scanner load_jobs → list[Job] → group(jobs) → list[Program]
13
+
14
+ ├──▶ CLI list / status / sync
15
+
16
+ └──▶ Picker fzf + apex preview, returns selected Program
17
+
18
+
19
+ Sync rsync per Job, mount check, Cmd hook
20
+ ```
21
+
22
+ ## Package layout
23
+
24
+ ```
25
+ lib/twin/
26
+ version.rb
27
+ config.rb ~/.config/twin/config.yaml loader
28
+ scanner.rb Job, Program structs; grubber + stat → grouped Programs
29
+ sync.rb rsync execution, mount check, post-sync hook
30
+ picker.rb fzf wrapper with apex preview
31
+ cli.rb subcommand dispatcher
32
+
33
+ bin/twin entrypoint
34
+ test/test_pure.rb
35
+ ```
36
+
37
+ ## Data model
38
+
39
+ **Job** — one YAML block:
40
+
41
+ ```
42
+ program, path, description, active, excludes, label, source, target, cmd, sync_file,
43
+ source_exists, target_exists, source_mtime, target_mtime, conflict
44
+ ```
45
+
46
+ `Job#status` → one of `disabled / both_missing / missing_source / missing_target /
47
+ target_newer / in_sync / source_newer`.
48
+
49
+ **Program** — group of Jobs sharing a `program` name:
50
+
51
+ ```
52
+ name, jobs
53
+ ```
54
+
55
+ `Program#status` aggregates jobs (worst state wins). Selection in the picker
56
+ operates on Programs, not individual Jobs.
57
+
58
+ ## Configuration
59
+
60
+ `~/.config/twin/config.yaml`:
61
+
62
+ ```yaml
63
+ sync_dir: /path/to/sync-files
64
+ global_excludes: [".DS_Store", ".git/"]
65
+ apex_theme: ralf
66
+ apex_width: 80
67
+ ```
68
+
69
+ Environment overrides: `TWIN_SYNC_DIR` (sync_dir), `TWIN_CONFIG` (config path).
70
+
71
+ ## Sync-files
72
+
73
+ Markdown files. Frontmatter is the sync-relationship (source/target). YAML
74
+ blocks define paths. grubber merges frontmatter into each block so every
75
+ record is self-contained.
76
+
77
+ Multiple blocks may share the same `Program` value — these are treated as
78
+ one logical unit by twin.
79
+
80
+ ## Picker
81
+
82
+ Two stages:
83
+
84
+ 1. **Stage 1 — program picker.** Multi-line NUL-separated entries (`--read0`).
85
+ Each entry has a header (icon, program name, job count, sync-file) and
86
+ indented body lines (one per job). No preview. Single-select. ESC exits.
87
+ 2. **Stage 2 — path multi-picker.** Tab-delimited rows (`id\tdisplay`),
88
+ `--with-nth=2` hides the `id`. Multi-select via Tab. Preview pane shows
89
+ the *compact* view (frontmatter + intro + the heading section containing
90
+ the YAML block for the highlighted path), rendered via
91
+ `apex --plugins -t terminal256`. ESC returns to Stage 1.
92
+
93
+ Compact previews are pre-rendered to per-job tempfiles before fzf launches.
94
+ An `awk` lookup maps `{1}` (the id) → tempfile path.
95
+
96
+ ## CLI
97
+
98
+ File argument resolution (`twin <arg>` and `--file=<arg>`):
99
+
100
+ - empty / missing → scan `sync_dir`, no filter
101
+ - bare name (no `/`) → scan `sync_dir`, filter by substring match
102
+ - path containing `/` → expand, then:
103
+ - directory → scan that directory, no filter
104
+ - file → scan parent directory, filter by basename
105
+ - neither → raise "not found: …"
106
+
107
+ Unknown options (anything starting with `-` that isn't `--help`) print an
108
+ error pointing at `twin --help` and exit 1.
109
+
110
+ ## Sync
111
+
112
+ Before syncing:
113
+
114
+ 1. **Mount check** — every unique target root must be a mount point
115
+ (`File.stat.dev != parent.dev`). Aborts if unmounted.
116
+ 2. **Conflict warning** — emits stderr listing jobs where the target is
117
+ newer than the source. Continues anyway (`rsync --update` skips them).
118
+
119
+ Then per Job:
120
+
121
+ ```
122
+ rsync -av --update [--exclude=...]* src/ tgt/
123
+ ```
124
+
125
+ If `Cmd` is set on the block and not in dry-run mode, the command is
126
+ executed via `sh -c` after a successful rsync.
127
+
128
+ ## External dependencies
129
+
130
+ | Tool | Purpose |
131
+ |-----------|--------------------------------------------|
132
+ | `grubber` | Markdown + YAML block extraction |
133
+ | `rsync` | File transfer |
134
+ | `fzf` | Interactive selection |
135
+ | `apex` | Markdown preview rendering in the terminal |
136
+
137
+ twin has no runtime gem dependencies — only stdlib (`yaml`, `json`,
138
+ `optparse`, `open3`, `fileutils`).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ralf Hülsmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # twin
2
+
3
+ Sync configuration folders between two Macs from self-documenting Markdown files.
4
+
5
+ Sync entries are defined in Markdown files with YAML blocks — human-readable,
6
+ self-documenting, and queryable via [grubber](https://github.com/rhsev/grubber).
7
+ Selection is interactive via [fzf](https://github.com/junegunn/fzf), with a
8
+ Markdown preview rendered by [apex](https://github.com/ttscoff/apex).
9
+
10
+ ## Why?
11
+
12
+ Sync-definitions in Markdown + YAML are three things at once:
13
+
14
+ - **Human-readable.** Plain Markdown, no twin-specific syntax to learn. The
15
+ Markdown frame documents *why* a path is synced, not just what, so you can
16
+ read your own sync-files in a year and still understand them.
17
+ - **Machine-readable.** The YAML blocks are queryable via grubber, so any tool
18
+ (twin, but also future ones) can act on the same source of truth.
19
+ - **AI-writable.** LLMs handle Markdown + YAML well. You can ask an assistant to
20
+ add new entries or refactor existing ones, and the result stays valid for both
21
+ humans and grubber.
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ twin # picker — all programs across all sync-files
27
+ twin home_macbook.md # picker — one sync-file in sync_dir (by name)
28
+ twin /abs/path/to/file.md # picker — any sync-file by absolute path
29
+ twin ./relative/dir/ # picker — all sync-files in a directory
30
+ twin list # plain listing
31
+ twin status # listing with source/target mtimes
32
+ twin sync -p grubber # sync one program by name pattern
33
+ twin sync --file=repos # sync all programs from a sync-file
34
+ twin sync --dry-run # preview without writing
35
+ twin --help # show usage
36
+ ```
37
+
38
+ File argument resolution:
39
+
40
+ - bare name (no `/`) → looked up by substring in `sync_dir`
41
+ - contains `/` → resolved as path (absolute or relative); file or directory both work
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ gem build twin.gemspec
47
+ gem install ./twin-*.gem
48
+ ```
49
+
50
+ External tools required in PATH: `grubber`, `rsync`, `fzf`. For the
51
+ stage-2 preview, one of `apex`, `glow`, or `bat` (falls back in that order;
52
+ `cat` if none are present).
53
+
54
+ ## Configuration
55
+
56
+ `~/.config/twin/config.yaml`:
57
+
58
+ ```yaml
59
+ sync_dir: /path/to/sync-files
60
+
61
+ global_excludes:
62
+ - .DS_Store
63
+ - .git/
64
+
65
+ apex_theme: ralf # optional
66
+ apex_width: 80 # optional
67
+ ```
68
+
69
+ Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`.
70
+
71
+ ## Sync-files
72
+
73
+ Each Markdown file represents one sync relationship. Frontmatter defines the
74
+ relationship; YAML blocks define individual paths.
75
+
76
+ Example:
77
+
78
+ ````markdown
79
+ ---
80
+ Active: 1
81
+ Label: mac-mini→macbook
82
+ Source: /Users/admin
83
+ Target: /Volumes/macbook/Users/admin
84
+ ---
85
+
86
+ ## Fish Shell
87
+
88
+ Configuration for the fish shell, including completions and abbreviations.
89
+
90
+ ```yaml
91
+ Program: Fish Shell
92
+ Path: .config/fish
93
+ Description: Fish Shell configuration
94
+ Exclude: conf.d/local.fish
95
+ ```
96
+ ````
97
+
98
+ Frontmatter fields (`Active`, `Label`, `Source`, `Target`) are merged into
99
+ every block by grubber. Multiple blocks can share the same `Program` — twin
100
+ groups them and treats the program as the unit of selection.
101
+
102
+ The optional `Cmd` field runs a shell command after a successful sync.
103
+
104
+ ## Design
105
+
106
+ - **Selection unit:** Program. A program may have several blocks (paths); the
107
+ picker shows one row per program.
108
+ - **Preview:** the whole sync-file rendered with `apex --plugins -t terminal256`.
109
+ - **No TUI framework:** `fzf` does the interactive part, `apex` the rendering.
110
+ Composition over framework.
111
+
112
+ See `ARCHITECTURE.md` for details.
113
+
114
+ ## Tests
115
+
116
+ ```bash
117
+ rake test
118
+ ```
data/bin/twin ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "twin"
6
+
7
+ Twin::CLI.run(ARGV)
data/lib/twin/cli.rb ADDED
@@ -0,0 +1,244 @@
1
+ require "optparse"
2
+ require "json"
3
+
4
+ require_relative "config"
5
+ require_relative "scanner"
6
+ require_relative "sync"
7
+ require_relative "picker"
8
+
9
+ module Twin
10
+ module CLI
11
+ module_function
12
+
13
+ USAGE = <<~TXT
14
+ twin — sync configuration files between machines
15
+
16
+ USAGE:
17
+ twin interactive picker (all sync-files)
18
+ twin <name> picker — sync-file by name in sync_dir
19
+ twin <path> picker — file or directory (absolute or relative)
20
+ twin list [--all] [--label X] [--file X] [--json]
21
+ twin status [--all] [--label X] [--file X] [--json]
22
+ twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
23
+ twin --help show this message
24
+
25
+ FILE ARGUMENT:
26
+ bare name (no /) → matched by substring against sync-file names
27
+ contains / → resolved as path; file or directory both work
28
+
29
+ CONFIG:
30
+ ~/.config/twin/config.yaml
31
+ TWIN_SYNC_DIR overrides sync_dir
32
+ TWIN_CONFIG overrides config path
33
+ TXT
34
+
35
+ def run(argv)
36
+ cfg = Twin::Config.load
37
+ cfg.validate!
38
+
39
+ first = argv.first
40
+ case first
41
+ when nil
42
+ pick_and_sync(cfg, file: nil)
43
+ when "list" then cmd_list(cfg, argv.drop(1))
44
+ when "status" then cmd_status(cfg, argv.drop(1))
45
+ when "sync" then cmd_sync(cfg, argv.drop(1))
46
+ when "-h", "--help", "help"
47
+ puts USAGE
48
+ when /\A-/
49
+ warn "unknown option: #{first}"
50
+ warn "Run 'twin --help' for usage."
51
+ exit 1
52
+ else
53
+ # Treat as file.md or directory path
54
+ pick_and_sync(cfg, file: first)
55
+ end
56
+ rescue => e
57
+ warn "error: #{e.message}"
58
+ exit 1
59
+ end
60
+
61
+ # ── picker → sync ──────────────────────────────────────────────────────────
62
+
63
+ def pick_and_sync(cfg, file:)
64
+ programs = Scanner.load_programs(cfg, file: file, show_all: false)
65
+ if programs.empty?
66
+ warn "no active programs found#{" in #{file}" if file}"
67
+ return
68
+ end
69
+
70
+ selected_key = nil # [name, sync_file] of last program — used to re-enter
71
+ loop do
72
+ program =
73
+ if selected_key
74
+ programs.find { |p| [p.name, p.sync_file] == selected_key }
75
+ else
76
+ Picker.pick_program(programs)
77
+ end
78
+ return unless program
79
+
80
+ jobs = Picker.pick_paths(program, cfg)
81
+ if jobs == :back # ESC in stage 2 → back to stage 1
82
+ selected_key = nil
83
+ next
84
+ end
85
+ if jobs.empty? # Enter without selection → exit
86
+ return
87
+ end
88
+
89
+ sync_jobs(cfg, program, jobs)
90
+
91
+ print "\npress Enter to continue, q to quit "
92
+ $stdout.flush
93
+ break if $stdin.gets&.strip == "q"
94
+
95
+ # reload so status reflects what was just synced, stay on this program
96
+ programs = Scanner.load_programs(cfg, file: file, show_all: false)
97
+ selected_key = [program.name, program.sync_file]
98
+ end
99
+ end
100
+
101
+ # ── list / status ──────────────────────────────────────────────────────────
102
+
103
+ def cmd_list(cfg, args)
104
+ opts = parse_filter_opts(args)
105
+ programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all))
106
+
107
+ if opts[:json]
108
+ puts JSON.pretty_generate(programs.map { |p| program_to_hash(p) })
109
+ return
110
+ end
111
+
112
+ programs.each do |p|
113
+ mark = p.status == :disabled ? "–" : "✓"
114
+ puts "#{mark} #{p.name} — #{p.description}"
115
+ end
116
+ end
117
+
118
+ def cmd_status(cfg, args)
119
+ opts = parse_filter_opts(args)
120
+ programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all))
121
+
122
+ if opts[:json]
123
+ puts JSON.pretty_generate(programs.map { |p| program_to_hash(p) })
124
+ return
125
+ end
126
+
127
+ tty = $stdout.tty?
128
+ programs.each do |p|
129
+ icon = Picker::STATUS_ICONS[p.status] || "?"
130
+ icon = Picker.colorize(p.status, icon) if tty
131
+ name = tty ? Picker.bold(p.name) : p.name
132
+ puts "#{icon} #{name}"
133
+ p.jobs.each do |j|
134
+ src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
135
+ tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
136
+ conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
137
+ j_icon = tty ? Picker.colorize(j.status, "") : ""
138
+ puts " #{j.path}#{conflict}"
139
+ puts " src #{src}"
140
+ puts " dst #{tgt}"
141
+ end
142
+ end
143
+ end
144
+
145
+ # ── sync ───────────────────────────────────────────────────────────────────
146
+
147
+ def cmd_sync(cfg, args)
148
+ opts = parse_sync_opts(args)
149
+ programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all))
150
+
151
+ if opts[:pattern]
152
+ programs = programs.select { |p| p.name.downcase.include?(opts[:pattern].downcase) }
153
+ end
154
+
155
+ if programs.empty?
156
+ puts "no matching programs"
157
+ return
158
+ end
159
+
160
+ programs.each { |p| sync_program(cfg, p, dry_run: opts[:dry_run]) }
161
+ end
162
+
163
+ def sync_program(cfg, program, dry_run: false)
164
+ sync_jobs(cfg, program, program.active_jobs, dry_run: dry_run)
165
+ end
166
+
167
+ def sync_jobs(cfg, program, jobs, dry_run: false)
168
+ jobs = jobs.select { |j| j.active == 1 }
169
+ return if jobs.empty?
170
+
171
+ # one mount check per unique target root
172
+ checked = Set.new
173
+ jobs.each do |j|
174
+ next if checked.include?(j.target)
175
+ unless Twin::Sync.mounted?(j.target)
176
+ warn "abort: #{j.target} is not a mounted volume"
177
+ exit 1
178
+ end
179
+ checked << j.target
180
+ end
181
+
182
+ conflicts = jobs.select(&:conflict)
183
+ unless conflicts.empty?
184
+ warn "warning: target is newer than source:"
185
+ conflicts.each { |j| warn " ! #{j.path}" }
186
+ warn "continuing sync (--update skips newer files on target)."
187
+ end
188
+
189
+ puts "→ #{program.name}"
190
+ jobs.each do |job|
191
+ success, output = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
192
+ puts " • #{job.path}"
193
+ puts output.gsub(/^/, " ") if output && !output.strip.empty?
194
+ warn " error syncing #{job.path}" unless success
195
+ end
196
+ end
197
+
198
+ # ── option parsing ─────────────────────────────────────────────────────────
199
+
200
+ def parse_filter_opts(args)
201
+ opts = { show_all: false, label: nil, file: nil, json: false }
202
+ OptionParser.new do |o|
203
+ o.on("--all") { opts[:show_all] = true }
204
+ o.on("--label=L") { |v| opts[:label] = v }
205
+ o.on("--file=F") { |v| opts[:file] = v }
206
+ o.on("--json") { opts[:json] = true }
207
+ end.parse!(args)
208
+ opts
209
+ end
210
+
211
+ def parse_sync_opts(args)
212
+ opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false }
213
+ OptionParser.new do |o|
214
+ o.on("--all") { opts[:show_all] = true }
215
+ o.on("--label=L") { |v| opts[:label] = v }
216
+ o.on("--file=F") { |v| opts[:file] = v }
217
+ o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
218
+ o.on("--dry-run") { opts[:dry_run] = true }
219
+ end.parse!(args)
220
+ opts
221
+ end
222
+
223
+ def program_to_hash(p)
224
+ {
225
+ name: p.name,
226
+ status: p.status,
227
+ sync_file: p.sync_file,
228
+ label: p.label,
229
+ description: p.description,
230
+ jobs: p.jobs.map { |j| job_to_hash(j) },
231
+ }
232
+ end
233
+
234
+ def job_to_hash(j)
235
+ h = j.to_h
236
+ h[:status] = j.status
237
+ h[:source_mtime] = j.source_mtime&.iso8601
238
+ h[:target_mtime] = j.target_mtime&.iso8601
239
+ h
240
+ end
241
+ end
242
+ end
243
+
244
+ require "set"
@@ -0,0 +1,46 @@
1
+ require "yaml"
2
+ require "pathname"
3
+
4
+ module Twin
5
+ class Config
6
+ attr_accessor :sync_dir, :global_excludes,
7
+ :apex_theme, :apex_width,
8
+ :apex_code_highlight, :apex_code_highlight_theme
9
+
10
+ DEFAULTS = {
11
+ "global_excludes" => [".DS_Store"],
12
+ "apex_theme" => nil,
13
+ "apex_width" => nil,
14
+ "apex_code_highlight" => nil,
15
+ "apex_code_highlight_theme" => nil,
16
+ }.freeze
17
+
18
+ def initialize(data = {})
19
+ merged = DEFAULTS.merge(data || {})
20
+ @sync_dir = ENV["TWIN_SYNC_DIR"] || merged["sync_dir"].to_s
21
+ @global_excludes = merged["global_excludes"] || []
22
+ @apex_theme = merged["apex_theme"]
23
+ @apex_width = merged["apex_width"]
24
+ @apex_code_highlight = merged["apex_code_highlight"]
25
+ @apex_code_highlight_theme = merged["apex_code_highlight_theme"]
26
+ end
27
+
28
+ def self.load
29
+ path = ENV["TWIN_CONFIG"] || File.join(Dir.home, ".config", "twin", "config.yaml")
30
+ data = {}
31
+ if File.exist?(path)
32
+ begin
33
+ data = YAML.safe_load_file(path) || {}
34
+ rescue Psych::SyntaxError => e
35
+ raise "config syntax error in #{path}: #{e.message}"
36
+ end
37
+ end
38
+ new(data)
39
+ end
40
+
41
+ def validate!
42
+ raise "sync_dir not set (add to ~/.config/twin/config.yaml or set TWIN_SYNC_DIR)" if sync_dir.empty?
43
+ raise "sync_dir not found: #{sync_dir}" unless Dir.exist?(sync_dir)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,209 @@
1
+ require "open3"
2
+ require "tempfile"
3
+
4
+ require_relative "preview"
5
+
6
+ module Twin
7
+ # Two-step fzf picker:
8
+ # pick_program → tabular multi-line list, no preview
9
+ # pick_paths → multi-select within one program, glow renders the sync-file
10
+ module Picker
11
+ module_function
12
+
13
+ STATUS_ICONS = {
14
+ source_newer: "→",
15
+ target_newer: "←",
16
+ in_sync: "✓",
17
+ missing_target: "!",
18
+ missing_source: "!",
19
+ both_missing: "✗",
20
+ disabled: "·",
21
+ }.freeze
22
+
23
+ STATUS_COLORS = {
24
+ source_newer: "\e[33m", # yellow
25
+ target_newer: "\e[36m", # cyan
26
+ in_sync: "\e[32m", # green
27
+ missing_target: "\e[31m", # red
28
+ missing_source: "\e[31m", # red
29
+ both_missing: "\e[31m", # red
30
+ disabled: "\e[2m", # dim
31
+ }.freeze
32
+
33
+ BOLD = "\e[1m"
34
+ DIM = "\e[2m"
35
+ RESET = "\e[0m"
36
+
37
+ SH_ENV = { "SHELL" => "/bin/sh" }.freeze
38
+
39
+ def colorize(status, text) = "#{STATUS_COLORS[status] || ''}#{text}#{RESET}"
40
+ def dim(text) = "#{DIM}#{text}#{RESET}"
41
+ def bold(text) = "#{BOLD}#{text}#{RESET}"
42
+
43
+ # ── Stufe 1: program picker ───────────────────────────────────────────────
44
+
45
+ # Multi-line entries (NUL-separated). Header line per program, indented
46
+ # body lines per job. Returns the selected Program or nil.
47
+ def pick_program(programs)
48
+ raise "fzf not found in PATH" unless which("fzf")
49
+ return nil if programs.empty?
50
+
51
+ name_width = programs.map { |p| p.name.length }.max
52
+ path_width = programs.flat_map { |p| p.jobs.map { |j| j.path.length } }.max
53
+
54
+ entries = programs.map { |p| render_program_entry(p, name_width, path_width) }
55
+ input = entries.join("\0")
56
+
57
+ fzf = [
58
+ "fzf",
59
+ "--read0", "--ansi", "--no-multi",
60
+ "--prompt=program> ",
61
+ "--height=100%", "--reverse",
62
+ "--no-sort",
63
+ "--color=bg+:-1,hl+:reverse",
64
+ ]
65
+
66
+ output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: input)
67
+ return nil unless status.success?
68
+ return nil if output.strip.empty?
69
+
70
+ first_line = output.lines.first.to_s
71
+ programs.find { |p| first_line.include?(" #{p.name} ") || first_line.rstrip.end_with?(p.name) || first_line.include?(p.name) }
72
+ end
73
+
74
+ # ── Stufe 2: path multi-picker ────────────────────────────────────────────
75
+
76
+ # Multi-select over the jobs of one program. Right pane shows the
77
+ # apex-rendered compact view (frontmatter + intro + selected block).
78
+ # Returns array of selected Jobs, :back on ESC, [] on empty confirm.
79
+ def pick_paths(program, cfg)
80
+ jobs = program.jobs
81
+ return [] if jobs.empty?
82
+
83
+ path_width = jobs.map { |j| j.path.length }.max
84
+ tempfiles = write_compact_previews(program, jobs)
85
+
86
+ rows = jobs.each_with_index.map do |j, i|
87
+ icon = STATUS_ICONS[j.status] || "?"
88
+ delta = format_delta(j.source_mtime, j.target_mtime)
89
+ line = "#{icon} #{j.path.ljust(path_width)} #{delta}"
90
+ "#{i}\t#{colorize(j.status, line)}"
91
+ end
92
+
93
+ preview_cmd = build_apex_preview_cmd(tempfiles, cfg)
94
+
95
+ fzf = [
96
+ "fzf",
97
+ "--multi", "--ansi",
98
+ "--delimiter=\t", "--with-nth=2",
99
+ "--prompt=#{program.name} > ",
100
+ "--header=#{program.name} — Tab toggles, Enter confirms",
101
+ "--preview=#{preview_cmd}",
102
+ "--preview-window=right:60%:wrap",
103
+ "--height=100%", "--reverse",
104
+ "--bind=ctrl-a:select-all",
105
+ "--color=bg+:-1,hl+:reverse",
106
+ ]
107
+
108
+ output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: rows.join("\n"))
109
+ return :back if status.exitstatus == 130 # ESC / Ctrl-C
110
+ return [] unless status.success?
111
+ return [] if output.strip.empty?
112
+
113
+ output.lines.filter_map do |line|
114
+ idx = line.split("\t", 2).first&.to_i
115
+ jobs[idx] if idx
116
+ end
117
+ ensure
118
+ tempfiles&.each_value { |path| File.unlink(path) rescue nil }
119
+ end
120
+
121
+ # Write per-job compact-preview markdown to tempfiles. Returns {idx => path}.
122
+ def write_compact_previews(program, jobs)
123
+ result = {}
124
+ jobs.each_with_index do |job, i|
125
+ compact = Preview.extract_compact(program.sync_file, job.path)
126
+ f = Tempfile.new(["twin-#{i}-", ".md"])
127
+ f.write(compact)
128
+ f.close
129
+ result[i] = f.path
130
+ end
131
+ result
132
+ end
133
+
134
+ def build_apex_preview_cmd(tempfiles, cfg)
135
+ # Map idx → file via a small TSV, awk picks the right one for {1}.
136
+ mapfile = Tempfile.new(["twin-map-", ".tsv"])
137
+ tempfiles.each { |i, path| mapfile.puts("#{i}\t#{path}") }
138
+ mapfile.close
139
+ ObjectSpace.define_finalizer(mapfile, ->(_) { File.unlink(mapfile.path) rescue nil })
140
+
141
+ render_cmd = pick_renderer(cfg)
142
+
143
+ %(F=$(awk -v id={1} -F'\\t' '$1==id {print $2}' #{mapfile.path}); ) +
144
+ %([ -n "$F" ] && #{render_cmd})
145
+ end
146
+
147
+ # Pick the first available markdown renderer.
148
+ def pick_renderer(cfg)
149
+ if which("apex")
150
+ args = ["--plugins", "-t", "terminal256"]
151
+ args += ["--theme", cfg.apex_theme] if cfg.apex_theme
152
+ args += ["--width", cfg.apex_width.to_s] if cfg.apex_width
153
+ args += ["--code-highlight", cfg.apex_code_highlight] if cfg.apex_code_highlight
154
+ args += ["--code-highlight-theme", cfg.apex_code_highlight_theme] if cfg.apex_code_highlight_theme
155
+ (["apex", '"$F"'] + args).join(" ") + " 2>/dev/null"
156
+ elsif which("glow")
157
+ %(glow -s dark "$F" 2>/dev/null)
158
+ elsif which("bat")
159
+ %(bat --color=always --language=markdown --style=plain "$F" 2>/dev/null)
160
+ else
161
+ %(cat "$F")
162
+ end
163
+ end
164
+
165
+ def which(cmd)
166
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
167
+ File.executable?(File.join(dir, cmd))
168
+ end
169
+ end
170
+
171
+ # ── Helpers ───────────────────────────────────────────────────────────────
172
+
173
+ def render_program_entry(program, name_width, path_width)
174
+ icon = colorize(program.status, STATUS_ICONS[program.status] || "?")
175
+ sync_file = File.basename(program.sync_file.to_s)
176
+ count = program.active_jobs.size
177
+ total = program.jobs.size
178
+ header = "#{icon} #{bold(program.name.ljust(name_width))} " \
179
+ "#{dim("(#{count}/#{total})")} #{dim("[#{sync_file}]")}"
180
+
181
+ body = program.jobs.map do |j|
182
+ icon = STATUS_ICONS[j.status] || "?"
183
+ delta = format_delta(j.source_mtime, j.target_mtime)
184
+ line = " #{icon} #{j.path.ljust(path_width)} #{delta}"
185
+ colorize(j.status, line)
186
+ end
187
+
188
+ ([header] + body).join("\n")
189
+ end
190
+
191
+ def format_delta(sm, tm)
192
+ return "" if sm.nil? || tm.nil?
193
+ seconds = (sm - tm).to_i
194
+ return "in sync" if seconds.abs < 60
195
+ label = seconds > 0 ? "src" : "tgt"
196
+ abs = seconds.abs
197
+ unit =
198
+ if abs >= 86400 then "#{abs / 86400}d"
199
+ elsif abs >= 3600 then "#{abs / 3600}h"
200
+ else "#{abs / 60}m"
201
+ end
202
+ "#{label} +#{unit}"
203
+ end
204
+
205
+ def shellesc(s)
206
+ "'" + s.to_s.gsub("'", %q['\\'']) + "'"
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,92 @@
1
+ module Twin
2
+ # Compact markdown preview for a single sync block: frontmatter +
3
+ # intro (text before the first heading) + the heading section that
4
+ # contains the YAML block for *target_path*.
5
+ module Preview
6
+ module_function
7
+
8
+ YAML_FENCE = /\A```ya?ml\s*\z/.freeze
9
+ FENCE_END = /\A```\s*\z/.freeze
10
+ HEADING = /\A(\#{1,6})\s/.freeze
11
+ H2_PLUS = /\A\#{2,6}\s/.freeze
12
+ PATH_KEY = /\APath:\s*['"]?([^'"]+?)['"]?\s*\z/.freeze
13
+
14
+ def extract_compact(file_path, target_path)
15
+ content = File.read(file_path, encoding: "UTF-8")
16
+
17
+ frontmatter, body = split_frontmatter(content)
18
+ lines = body.lines.map(&:chomp)
19
+
20
+ intro = extract_intro(lines)
21
+ section = extract_section_for(lines, target_path)
22
+
23
+ [frontmatter, intro, section].reject(&:empty?).join("\n\n") + "\n"
24
+ rescue Errno::ENOENT
25
+ ""
26
+ end
27
+
28
+ def split_frontmatter(content)
29
+ return ["", content] unless content.start_with?("---\n") || content.start_with?("---\r\n")
30
+ parts = content.split(/^---\s*$\n/, 3)
31
+ return ["", content] if parts.size < 3
32
+ ["---\n#{parts[1]}---", parts[2]]
33
+ end
34
+
35
+ # Intro = everything up to the first H2 (H1 + body counts as document title).
36
+ def extract_intro(lines)
37
+ idx = lines.index { |l| l =~ H2_PLUS }
38
+ idx = lines.size unless idx
39
+ lines[0...idx].join("\n").strip
40
+ end
41
+
42
+ # Locate the YAML block whose Path: matches *target*, then return the
43
+ # surrounding heading section (nearest preceding heading until the next
44
+ # heading of equal-or-higher level).
45
+ def extract_section_for(lines, target)
46
+ block_start, block_end = find_block(lines, target)
47
+ return "" unless block_start
48
+
49
+ section_start = block_start
50
+ (block_start - 1).downto(0) do |i|
51
+ if lines[i] =~ HEADING
52
+ section_start = i
53
+ break
54
+ end
55
+ end
56
+
57
+ section_end = lines.size - 1
58
+ if (m = lines[section_start]&.match(HEADING))
59
+ level = m[1].length
60
+ ((block_end + 1)...lines.size).each do |i|
61
+ if (m2 = lines[i].match(HEADING)) && m2[1].length <= level
62
+ section_end = i - 1
63
+ break
64
+ end
65
+ end
66
+ end
67
+
68
+ lines[section_start..section_end].join("\n").strip
69
+ end
70
+
71
+ def find_block(lines, target)
72
+ i = 0
73
+ while i < lines.size
74
+ if lines[i] =~ YAML_FENCE
75
+ k = i + 1
76
+ matched = false
77
+ while k < lines.size && lines[k] !~ FENCE_END
78
+ if (m = lines[k].match(PATH_KEY)) && m[1] == target
79
+ matched = true
80
+ end
81
+ k += 1
82
+ end
83
+ return [i, k] if matched
84
+ i = k + 1
85
+ else
86
+ i += 1
87
+ end
88
+ end
89
+ nil
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,141 @@
1
+ require "json"
2
+ require "open3"
3
+
4
+ module Twin
5
+ # One YAML block from a sync-file, enriched with live filesystem state.
6
+ Job = Struct.new(
7
+ :program, :path, :description, :active, :excludes, :label,
8
+ :source, :target, :cmd, :sync_file,
9
+ :source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
10
+ keyword_init: true,
11
+ ) do
12
+ def source_path = File.join(source, path)
13
+ def target_path = File.join(target, path)
14
+
15
+ def status
16
+ return :disabled if active != 1
17
+ return :both_missing if !source_exists && !target_exists
18
+ return :missing_source unless source_exists
19
+ return :missing_target unless target_exists
20
+ return :target_newer if conflict
21
+ return :in_sync if source_mtime.nil? || target_mtime.nil?
22
+ delta = source_mtime - target_mtime
23
+ return :in_sync if delta.abs < 60
24
+ delta > 0 ? :source_newer : :target_newer
25
+ end
26
+ end
27
+
28
+ # A logical group of Jobs sharing a Program name (e.g. "Matterbase" can have
29
+ # multiple paths). Selection unit in the picker.
30
+ Program = Struct.new(:name, :jobs, keyword_init: true) do
31
+ def sync_file = jobs.first&.sync_file
32
+ def label = jobs.first&.label
33
+
34
+ def description
35
+ jobs.map(&:description).reject(&:empty?).uniq.join(" / ")
36
+ end
37
+
38
+ # Aggregate status across jobs — worst first.
39
+ def status
40
+ states = jobs.map(&:status)
41
+ %i[both_missing missing_source missing_target target_newer source_newer disabled in_sync]
42
+ .find { |s| states.include?(s) } || :in_sync
43
+ end
44
+
45
+ def active_jobs = jobs.select { |j| j.active == 1 }
46
+
47
+ def newest_source_mtime = jobs.map(&:source_mtime).compact.max
48
+ def newest_target_mtime = jobs.map(&:target_mtime).compact.max
49
+ end
50
+
51
+ module Scanner
52
+ module_function
53
+
54
+ def load_jobs(cfg, scan_path: nil)
55
+ raise "grubber not found in PATH" unless system("command -v grubber > /dev/null 2>&1")
56
+
57
+ dir = scan_path || cfg.sync_dir
58
+ stdout, stderr, status = Open3.capture3(
59
+ "grubber", "extract", dir, "-b", "--format", "json"
60
+ )
61
+ raise "grubber: #{stderr.force_encoding('UTF-8').strip}" unless status.success?
62
+
63
+ begin
64
+ records = JSON.parse(stdout.force_encoding("UTF-8"))
65
+ rescue JSON::ParserError => e
66
+ raise "grubber returned invalid JSON: #{e.message}"
67
+ end
68
+ records.filter_map { |r| build_job(r) }
69
+ end
70
+
71
+ def load_programs(cfg, file: nil, label: nil, show_all: false)
72
+ scan_path, name_filter = resolve_file_arg(cfg, file)
73
+ jobs = load_jobs(cfg, scan_path: scan_path)
74
+ jobs = jobs.select { |j| j.sync_file.include?(name_filter) } if name_filter
75
+ jobs = jobs.select { |j| j.label == label } if label && !label.empty?
76
+ jobs = jobs.select { |j| j.active == 1 } unless show_all
77
+ group(jobs)
78
+ end
79
+
80
+ # Returns [scan_path, name_filter] for a given file argument.
81
+ # - nil / empty → [nil, nil] scan sync_dir, no filter
82
+ # - path to a dir → [dir, nil] scan that dir, no filter
83
+ # - path to a file → [dirname, basename] scan parent dir, filter by filename
84
+ # - bare name (no /) → [nil, name] scan sync_dir, filter by name
85
+ def resolve_file_arg(cfg, file)
86
+ return [nil, nil] if file.nil? || file.empty?
87
+ if file.include?("/") || file == "." || file == ".."
88
+ expanded = File.expand_path(file)
89
+ return [expanded, nil] if File.directory?(expanded)
90
+ return [File.dirname(expanded), File.basename(expanded)] if File.file?(expanded)
91
+ raise "not found: #{file}"
92
+ end
93
+ [nil, file]
94
+ end
95
+
96
+ def group(jobs)
97
+ jobs.group_by { |j| [j.program, j.sync_file] }
98
+ .map { |(name, _file), js| Program.new(name: name, jobs: js) }
99
+ end
100
+
101
+ def build_job(r)
102
+ path = r["Path"].to_s
103
+ source = r["Source"].to_s
104
+ target = r["Target"].to_s
105
+ return nil if path.empty? || source.empty? || target.empty?
106
+
107
+ excludes = (r["Exclude"] || "").split(",").map(&:strip).reject(&:empty?)
108
+
109
+ src_full = File.join(source, path)
110
+ tgt_full = File.join(target, path)
111
+ src_exists, src_mtime = stat(src_full)
112
+ tgt_exists, tgt_mtime = stat(tgt_full)
113
+ conflict = src_exists && tgt_exists && tgt_mtime && src_mtime && tgt_mtime > src_mtime
114
+
115
+ Job.new(
116
+ program: r["Program"].to_s,
117
+ path: path,
118
+ description: r["Description"].to_s,
119
+ active: (r["Active"] || 0).to_i,
120
+ excludes: excludes,
121
+ label: r["Label"].to_s,
122
+ source: source,
123
+ target: target,
124
+ cmd: r["Cmd"].to_s,
125
+ sync_file: r["_note_file"].to_s,
126
+ source_exists: src_exists,
127
+ target_exists: tgt_exists,
128
+ source_mtime: src_mtime,
129
+ target_mtime: tgt_mtime,
130
+ conflict: !!conflict,
131
+ )
132
+ end
133
+
134
+ def stat(path)
135
+ st = File.stat(path)
136
+ [true, st.mtime]
137
+ rescue Errno::ENOENT, Errno::EACCES
138
+ [false, nil]
139
+ end
140
+ end
141
+ end
data/lib/twin/sync.rb ADDED
@@ -0,0 +1,67 @@
1
+ require "fileutils"
2
+
3
+ module Twin
4
+ module Sync
5
+ module_function
6
+
7
+ # True if the path lives on a mounted volume other than the root filesystem.
8
+ # Walks up parents until it finds a mount point (different device than parent)
9
+ # or hits "/" (path is on the root volume, not externally mounted).
10
+ def mounted?(path)
11
+ return false unless File.exist?(path)
12
+ current = File.expand_path(path)
13
+ until current == "/"
14
+ parent = File.expand_path("..", current)
15
+ return true if File.stat(current).dev != File.stat(parent).dev
16
+ current = parent
17
+ end
18
+ false
19
+ rescue Errno::ENOENT
20
+ false
21
+ end
22
+
23
+ # Sync one Job. Returns [success, combined_output].
24
+ def run_job(cfg, job, dry_run: false)
25
+ src = job.source_path
26
+ tgt = job.target_path
27
+
28
+ return [false, "source not found: #{src}"] unless File.exist?(src)
29
+
30
+ FileUtils.mkdir_p(File.dirname(tgt))
31
+
32
+ args = ["rsync", "-av", "--update"]
33
+ args << "--dry-run" if dry_run
34
+ cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
35
+ job.excludes.each { |ex| args << "--exclude=#{ex}" }
36
+
37
+ if File.directory?(src)
38
+ args << "#{src}/" << "#{tgt}/"
39
+ else
40
+ args << src << tgt
41
+ end
42
+
43
+ output, status = run(args)
44
+ return [false, output] unless status.success?
45
+
46
+ if !job.cmd.empty? && !dry_run
47
+ cmd_out, _ = run(["sh", "-c", job.cmd])
48
+ output += "\ncmd: #{job.cmd}\n#{cmd_out}"
49
+ end
50
+
51
+ [true, output]
52
+ end
53
+
54
+ # Sync all jobs in a Program. Returns array of [job, success, output].
55
+ def run_program(cfg, program, dry_run: false)
56
+ program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
57
+ end
58
+
59
+ def run(args)
60
+ require "open3"
61
+ stdout, stderr, status = Open3.capture3(*args)
62
+ [stdout + stderr, status]
63
+ rescue Errno::ENOENT
64
+ raise "command not found: #{args.first}"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,3 @@
1
+ module Twin
2
+ VERSION = "0.1.0"
3
+ end
data/lib/twin.rb ADDED
@@ -0,0 +1,7 @@
1
+ require_relative "twin/version"
2
+ require_relative "twin/config"
3
+ require_relative "twin/scanner"
4
+ require_relative "twin/sync"
5
+ require_relative "twin/preview"
6
+ require_relative "twin/picker"
7
+ require_relative "twin/cli"
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: grubber-twin
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ralf Hülsmann
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ description: twin reads sync-files (Markdown + YAML blocks) via grubber, groups them
27
+ by program, and runs rsync. Interactive picker uses fzf with an apex Markdown preview.
28
+ email:
29
+ - huelsmann@sevelen.net
30
+ executables:
31
+ - twin
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ARCHITECTURE.md
36
+ - LICENSE
37
+ - README.md
38
+ - bin/twin
39
+ - lib/twin.rb
40
+ - lib/twin/cli.rb
41
+ - lib/twin/config.rb
42
+ - lib/twin/picker.rb
43
+ - lib/twin/preview.rb
44
+ - lib/twin/scanner.rb
45
+ - lib/twin/sync.rb
46
+ - lib/twin/version.rb
47
+ homepage: https://github.com/rhsev/grubber-twin
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ source_code_uri: https://github.com/rhsev/grubber-twin
52
+ bug_tracker_uri: https://github.com/rhsev/grubber-twin/issues
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.1'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.4
68
+ specification_version: 4
69
+ summary: Sync configuration folders between two Macs from self-documenting Markdown
70
+ files
71
+ test_files: []