mark-twin 0.1.3

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: 70005ba12f4a11cde04a57fd68615d3478d2ca974f2aaa6eb49d650c2201a9ac
4
+ data.tar.gz: c0e5e45e52cdb20c5758ce11c0b81fdc20b309a1cc9c637a146833b001424e05
5
+ SHA512:
6
+ metadata.gz: 05ae1ff58350d3fcde9d3ac959859f474c585d1e9a11ce5141a36093aa77d597362529c33bc17b4774b7eb7aecf1330c70baf84392a6de081b2fab3ee7c918cc
7
+ data.tar.gz: 7a730c9622eaaf684b5f4805fb93a19d84a53344b415eec873ab14701ae3a6b77515a2806437e333b408db4a742397390b1f02bc7c4ad6791585ac480e27e1b7
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,203 @@
1
+ # twin
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/mark-twin.svg)](https://rubygems.org/gems/mark-twin)
4
+ [![Tests](https://github.com/rhsev/mark-twin/actions/workflows/test.yml/badge.svg)](https://github.com/rhsev/mark-twin/actions/workflows/test.yml)
5
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Sync configuration folders between two Macs from self-documenting Markdown files.
8
+
9
+ Sync entries are defined in Markdown files with YAML blocks — human-readable,
10
+ self-documenting, and queryable via [grubber](https://github.com/rhsev/grubber).
11
+ Selection is interactive via [fzf](https://github.com/junegunn/fzf), with a
12
+ Markdown preview rendered by [apex](https://github.com/ttscoff/apex) and
13
+ optional post-sync actions on the target via [mi.lan](https://github.com/rhsev/mi.lan).
14
+
15
+ ## Why?
16
+
17
+ Sync-definitions in Markdown + YAML are three things at once:
18
+
19
+ - **Human-readable.** Plain Markdown, no twin-specific syntax to learn. The
20
+ Markdown frame documents *why* a path is synced, not just what, so you can
21
+ read your own sync-files in a year and still understand them.
22
+ - **Machine-readable.** The YAML blocks are queryable via grubber, so any tool
23
+ (twin, but also future ones) can act on the same source of truth.
24
+ - **AI-writable.** LLMs handle Markdown + YAML well. You can ask an assistant to
25
+ add new entries or refactor existing ones, and the result stays valid for both
26
+ humans and grubber.
27
+
28
+ ## Screenshots
29
+
30
+ Stage 1 — program picker. One row per program, color-coded status, indented
31
+ paths underneath:
32
+
33
+ ![Stage 1 — program picker](https://raw.githubusercontent.com/rhsev/mark-twin/main/docs/stage_1.png)
34
+
35
+ Stage 2 — multi-select over the paths of one program. The right pane shows a
36
+ compact preview of the relevant sync-file section, rendered by apex:
37
+
38
+ ![Stage 2 — Fish Shell paths with apex preview](https://raw.githubusercontent.com/rhsev/mark-twin/main/docs/stage_2_fish.png)
39
+
40
+ ## Installation
41
+
42
+ ### 1. Install grubber
43
+
44
+ twin parses sync-files via [grubber](https://github.com/rhsev/grubber), a
45
+ small Go binary. Download the latest release for your platform from
46
+ [github.com/rhsev/grubber/releases](https://github.com/rhsev/grubber/releases)
47
+ and put it somewhere in your `PATH` (e.g. `/usr/local/bin/grubber`).
48
+
49
+ ### 2. Install twin
50
+
51
+ ```bash
52
+ gem install mark-twin
53
+ ```
54
+
55
+ Or from source:
56
+
57
+ ```bash
58
+ git clone https://github.com/rhsev/mark-twin.git
59
+ cd mark-twin
60
+ gem build mark-twin.gemspec
61
+ gem install ./mark-twin-*.gem
62
+ ```
63
+
64
+ ### 3. Other tools
65
+
66
+ Also required in `PATH`: `rsync` (preinstalled on macOS), `fzf`
67
+ (`brew install fzf`). For the stage-2 preview, one of `apex`, `glow`,
68
+ or `bat` is recommended (falls back in that order; `cat` if none are
69
+ present).
70
+
71
+ ## Quickstart
72
+
73
+ Twin assumes the target machine is reachable as a mounted volume (typically
74
+ via SMB or NFS). The mount check is enforced before any sync.
75
+
76
+ 1. **Pick a directory for sync-files** (anywhere; this example uses `~/Sync`):
77
+
78
+ ```bash
79
+ mkdir -p ~/Sync
80
+ ```
81
+
82
+ 2. **Create the config** at `~/.config/twin/config.yaml`:
83
+
84
+ ```yaml
85
+ sync_dir: ~/Sync
86
+ global_excludes:
87
+ - .DS_Store
88
+ - .git/
89
+ ```
90
+
91
+ 3. **Drop a sync-file** into `~/Sync`. The simplest starting point is to copy
92
+ one of the [examples](examples/) and adapt the frontmatter:
93
+
94
+ ```bash
95
+ cp examples/home.md ~/Sync/
96
+ $EDITOR ~/Sync/home.md # edit Source: and Target:
97
+ ```
98
+
99
+ 4. **Run twin**:
100
+
101
+ ```bash
102
+ twin
103
+ ```
104
+
105
+ Pick a program, then the paths to sync, hit Enter.
106
+
107
+ ## Usage
108
+
109
+ Twin has two modes: an **interactive interface** (default — see screenshots
110
+ above) and **CLI commands** for status checks and batch sync.
111
+
112
+ ```bash
113
+ twin # TUI — all programs across all sync-files
114
+ twin home.md # TUI — one sync-file in sync_dir (by name)
115
+ twin /abs/path/to/file.md # TUI — any sync-file by absolute path
116
+ twin ./relative/dir/ # TUI — all sync-files in a directory
117
+ twin list # plain listing
118
+ twin status # listing with source/target mtimes
119
+ twin sync -p grubber # sync one program by name pattern
120
+ twin sync --file=repos # sync all programs from a sync-file
121
+ twin sync --dry-run # preview without writing
122
+ twin --help # show usage
123
+ ```
124
+
125
+ File argument resolution:
126
+
127
+ - bare name (no `/`) → looked up by substring in `sync_dir`
128
+ - contains `/` → resolved as path (absolute or relative); file or directory both work
129
+
130
+ ## Configuration
131
+
132
+ `~/.config/twin/config.yaml`:
133
+
134
+ ```yaml
135
+ sync_dir: /path/to/sync-files
136
+
137
+ global_excludes:
138
+ - .DS_Store
139
+ - .git/
140
+
141
+ # Optional preview rendering (apex):
142
+ # apex_theme: default
143
+ # apex_width: 80
144
+ # apex_code_highlight: monokai
145
+ # apex_code_highlight_theme: dark
146
+ ```
147
+
148
+ Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`.
149
+
150
+ ## Sync-files
151
+
152
+ Each Markdown file represents one sync relationship. Frontmatter defines the
153
+ relationship (Source/Target); YAML blocks define individual paths.
154
+
155
+ See [examples/home.md](examples/home.md) and [examples/repos.md](examples/repos.md)
156
+ for ready-to-adapt templates.
157
+
158
+ Minimal example:
159
+
160
+ ````markdown
161
+ ---
162
+ Active: 1
163
+ Label: mac-mini → macbook
164
+ Source: /Users/admin
165
+ Target: /Volumes/macbook/Users/admin
166
+ ---
167
+
168
+ ## Fish Shell
169
+
170
+ Configuration for the fish shell, including completions and abbreviations.
171
+
172
+ ```yaml
173
+ Program: Fish Shell
174
+ Path: .config/fish
175
+ Description: Fish Shell configuration
176
+ Exclude: conf.d/local.fish
177
+ ```
178
+ ````
179
+
180
+ Frontmatter fields (`Active`, `Label`, `Source`, `Target`) are merged into
181
+ every block by grubber. Multiple blocks can share the same `Program` — twin
182
+ groups them and treats the program as the unit of selection.
183
+
184
+ The optional `Cmd` field is where the hidden trick happens: after a
185
+ successful sync, twin runs an arbitrary shell command — typically a `curl`
186
+ to a local automation endpoint like [mi.lan](https://github.com/rhsev/mi.lan) —
187
+ to reload the program, run an installer, restart a service, or notify
188
+ another machine. One config sync, one config *deployed*. See the Helix
189
+ entry in [examples/home.md](examples/home.md).
190
+
191
+ ## Design
192
+
193
+ Sync instructions and context in one place — the same Markdown file holds
194
+ both the `Path:` directives and the prose explaining them. No TUI framework:
195
+ `fzf` does the interactive part, `apex` the rendering.
196
+
197
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
198
+
199
+ ## Tests
200
+
201
+ ```bash
202
+ rake test
203
+ ```
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,242 @@
1
+ require "optparse"
2
+ require "json"
3
+ require "set"
4
+
5
+ require_relative "config"
6
+ require_relative "scanner"
7
+ require_relative "sync"
8
+ require_relative "picker"
9
+
10
+ module Twin
11
+ module CLI
12
+ module_function
13
+
14
+ USAGE = <<~TXT
15
+ twin — sync configuration files between machines
16
+
17
+ USAGE:
18
+ twin interactive picker (all sync-files)
19
+ twin <name> picker — sync-file by name in sync_dir
20
+ twin <path> picker — file or directory (absolute or relative)
21
+ twin list [--all] [--label X] [--file X] [--json]
22
+ twin status [--all] [--label X] [--file X] [--json]
23
+ twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
24
+ twin --help show this message
25
+
26
+ FILE ARGUMENT:
27
+ bare name (no /) → matched by substring against sync-file names
28
+ contains / → resolved as path; file or directory both work
29
+
30
+ CONFIG:
31
+ ~/.config/twin/config.yaml
32
+ TWIN_SYNC_DIR overrides sync_dir
33
+ TWIN_CONFIG overrides config path
34
+ TXT
35
+
36
+ def run(argv)
37
+ cfg = Twin::Config.load
38
+ cfg.validate!
39
+
40
+ first = argv.first
41
+ case first
42
+ when nil
43
+ pick_and_sync(cfg, file: nil)
44
+ when "list" then cmd_list(cfg, argv.drop(1))
45
+ when "status" then cmd_status(cfg, argv.drop(1))
46
+ when "sync" then cmd_sync(cfg, argv.drop(1))
47
+ when "-h", "--help", "help"
48
+ puts USAGE
49
+ when /\A-/
50
+ warn "unknown option: #{first}"
51
+ warn "Run 'twin --help' for usage."
52
+ exit 1
53
+ else
54
+ # Treat as file.md or directory path
55
+ pick_and_sync(cfg, file: first)
56
+ end
57
+ rescue => e
58
+ warn "error: #{e.message}"
59
+ exit 1
60
+ end
61
+
62
+ # ── picker → sync ──────────────────────────────────────────────────────────
63
+
64
+ def pick_and_sync(cfg, file:)
65
+ programs = Scanner.load_programs(cfg, file: file, show_all: false)
66
+ if programs.empty?
67
+ warn "no active programs found#{" in #{file}" if file}"
68
+ return
69
+ end
70
+
71
+ selected_key = nil # [name, sync_file] of last program — used to re-enter
72
+ loop do
73
+ program =
74
+ if selected_key
75
+ programs.find { |p| [p.name, p.sync_file] == selected_key }
76
+ else
77
+ Picker.pick_program(programs)
78
+ end
79
+ return unless program
80
+
81
+ jobs = Picker.pick_paths(program, cfg)
82
+ if jobs == :back # ESC in stage 2 → back to stage 1
83
+ selected_key = nil
84
+ next
85
+ end
86
+ if jobs.empty? # Enter without selection → exit
87
+ return
88
+ end
89
+
90
+ sync_jobs(cfg, program, jobs)
91
+
92
+ print "\npress Enter to continue, q to quit "
93
+ $stdout.flush
94
+ break if $stdin.gets&.strip == "q"
95
+
96
+ # reload so status reflects what was just synced, stay on this program
97
+ programs = Scanner.load_programs(cfg, file: file, show_all: false)
98
+ selected_key = [program.name, program.sync_file]
99
+ end
100
+ end
101
+
102
+ # ── list / status ──────────────────────────────────────────────────────────
103
+
104
+ def cmd_list(cfg, args)
105
+ opts = parse_filter_opts(args)
106
+ programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all))
107
+
108
+ if opts[:json]
109
+ puts JSON.pretty_generate(programs.map { |p| program_to_hash(p) })
110
+ return
111
+ end
112
+
113
+ programs.each do |p|
114
+ mark = p.status == :disabled ? "–" : "✓"
115
+ puts "#{mark} #{p.name} — #{p.description}"
116
+ end
117
+ end
118
+
119
+ def cmd_status(cfg, args)
120
+ opts = parse_filter_opts(args)
121
+ programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all))
122
+
123
+ if opts[:json]
124
+ puts JSON.pretty_generate(programs.map { |p| program_to_hash(p) })
125
+ return
126
+ end
127
+
128
+ tty = $stdout.tty?
129
+ programs.each do |p|
130
+ icon = Picker::STATUS_ICONS[p.status] || "?"
131
+ icon = Picker.colorize(p.status, icon) if tty
132
+ name = tty ? Picker.bold(p.name) : p.name
133
+ puts "#{icon} #{name}"
134
+ p.jobs.each do |j|
135
+ src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
136
+ tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
137
+ conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
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
@@ -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,213 @@
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.each_with_index.map do |p, i|
55
+ "#{i}\t#{render_program_entry(p, name_width, path_width)}"
56
+ end
57
+ input = entries.join("\0")
58
+
59
+ fzf = [
60
+ "fzf",
61
+ "--read0", "--ansi", "--no-multi",
62
+ "--delimiter=\t", "--with-nth=2..",
63
+ "--prompt=program> ",
64
+ "--height=100%", "--reverse",
65
+ "--no-sort",
66
+ "--color=bg+:-1,hl+:reverse",
67
+ ]
68
+
69
+ output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: input)
70
+ return nil unless status.success?
71
+ return nil if output.strip.empty?
72
+
73
+ idx = output.split("\t", 2).first
74
+ programs[idx.to_i] if idx&.match?(/\A\d+\z/)
75
+ end
76
+
77
+ # ── Stufe 2: path multi-picker ────────────────────────────────────────────
78
+
79
+ # Multi-select over the jobs of one program. Right pane shows the
80
+ # apex-rendered compact view (frontmatter + intro + selected block).
81
+ # Returns array of selected Jobs, :back on ESC, [] on empty confirm.
82
+ def pick_paths(program, cfg)
83
+ jobs = program.jobs
84
+ return [] if jobs.empty?
85
+
86
+ path_width = jobs.map { |j| j.path.length }.max
87
+ tempfiles = write_compact_previews(program, jobs)
88
+ preview_cmd, mapfile = build_apex_preview_cmd(tempfiles, cfg)
89
+
90
+ rows = jobs.each_with_index.map do |j, i|
91
+ icon = STATUS_ICONS[j.status] || "?"
92
+ delta = format_delta(j.source_mtime, j.target_mtime)
93
+ line = "#{icon} #{j.path.ljust(path_width)} #{delta}"
94
+ "#{i}\t#{colorize(j.status, line)}"
95
+ end
96
+
97
+ fzf = [
98
+ "fzf",
99
+ "--multi", "--ansi",
100
+ "--delimiter=\t", "--with-nth=2",
101
+ "--prompt=#{program.name} > ",
102
+ "--header=#{program.name} — Tab toggles, Enter confirms",
103
+ "--preview=#{preview_cmd}",
104
+ "--preview-window=right:60%:wrap",
105
+ "--height=100%", "--reverse",
106
+ "--bind=ctrl-a:select-all",
107
+ "--color=bg+:-1,hl+:reverse",
108
+ ]
109
+
110
+ output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: rows.join("\n"))
111
+ return :back if status.exitstatus == 130 # ESC / Ctrl-C
112
+ return [] unless status.success?
113
+ return [] if output.strip.empty?
114
+
115
+ output.lines.filter_map do |line|
116
+ idx = line.split("\t", 2).first&.to_i
117
+ jobs[idx] if idx
118
+ end
119
+ ensure
120
+ tempfiles&.each_value { |path| File.unlink(path) rescue nil }
121
+ File.unlink(mapfile) rescue nil if mapfile
122
+ end
123
+
124
+ # Write per-job compact-preview markdown to tempfiles. Returns {idx => path}.
125
+ def write_compact_previews(program, jobs)
126
+ result = {}
127
+ jobs.each_with_index do |job, i|
128
+ compact = Preview.extract_compact(program.sync_file, job.path)
129
+ f = Tempfile.new(["twin-#{i}-", ".md"])
130
+ f.write(compact)
131
+ f.close
132
+ result[i] = f.path
133
+ end
134
+ result
135
+ end
136
+
137
+ # Returns [preview_cmd, mapfile_path]; the caller unlinks the mapfile.
138
+ def build_apex_preview_cmd(tempfiles, cfg)
139
+ # Map idx → file via a small TSV, awk picks the right one for {1}.
140
+ mapfile = Tempfile.new(["twin-map-", ".tsv"])
141
+ tempfiles.each { |i, path| mapfile.puts("#{i}\t#{path}") }
142
+ mapfile.close
143
+
144
+ render_cmd = pick_renderer(cfg)
145
+
146
+ cmd = %(F=$(awk -v id={1} -F'\\t' '$1==id {print $2}' #{mapfile.path}); ) +
147
+ %([ -n "$F" ] && #{render_cmd})
148
+ [cmd, mapfile.path]
149
+ end
150
+
151
+ # Pick the first available markdown renderer.
152
+ def pick_renderer(cfg)
153
+ if which("apex")
154
+ args = ["--plugins", "-t", "terminal256"]
155
+ args += ["--theme", shellesc(cfg.apex_theme)] if cfg.apex_theme
156
+ args += ["--width", shellesc(cfg.apex_width)] if cfg.apex_width
157
+ args += ["--code-highlight", shellesc(cfg.apex_code_highlight)] if cfg.apex_code_highlight
158
+ args += ["--code-highlight-theme", shellesc(cfg.apex_code_highlight_theme)] if cfg.apex_code_highlight_theme
159
+ (["apex", '"$F"'] + args).join(" ") + " 2>/dev/null"
160
+ elsif which("glow")
161
+ %(glow -s dark "$F" 2>/dev/null)
162
+ elsif which("bat")
163
+ %(bat --color=always --language=markdown --style=plain "$F" 2>/dev/null)
164
+ else
165
+ %(cat "$F")
166
+ end
167
+ end
168
+
169
+ def which(cmd)
170
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
171
+ File.executable?(File.join(dir, cmd))
172
+ end
173
+ end
174
+
175
+ # ── Helpers ───────────────────────────────────────────────────────────────
176
+
177
+ def render_program_entry(program, name_width, path_width)
178
+ icon = colorize(program.status, STATUS_ICONS[program.status] || "?")
179
+ sync_file = File.basename(program.sync_file.to_s)
180
+ count = program.active_jobs.size
181
+ total = program.jobs.size
182
+ header = "#{icon} #{bold(program.name.ljust(name_width))} " \
183
+ "#{dim("(#{count}/#{total})")} #{dim("[#{sync_file}]")}"
184
+
185
+ body = program.jobs.map do |j|
186
+ icon = STATUS_ICONS[j.status] || "?"
187
+ delta = format_delta(j.source_mtime, j.target_mtime)
188
+ line = " #{icon} #{j.path.ljust(path_width)} #{delta}"
189
+ colorize(j.status, line)
190
+ end
191
+
192
+ ([header] + body).join("\n")
193
+ end
194
+
195
+ def format_delta(sm, tm)
196
+ return "" if sm.nil? || tm.nil?
197
+ seconds = (sm - tm).to_i
198
+ return "in sync" if seconds.abs < 60
199
+ label = seconds > 0 ? "src" : "tgt"
200
+ abs = seconds.abs
201
+ unit =
202
+ if abs >= 86400 then "#{abs / 86400}d"
203
+ elsif abs >= 3600 then "#{abs / 3600}h"
204
+ else "#{abs / 60}m"
205
+ end
206
+ "#{label} +#{unit}"
207
+ end
208
+
209
+ def shellesc(s)
210
+ "'" + s.to_s.gsub("'", %q['\\'']) + "'"
211
+ end
212
+ end
213
+ 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,143 @@
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
+ # Same 60s tolerance as Job#status, so mtime jitter never flags a conflict.
114
+ conflict = src_exists && tgt_exists && tgt_mtime && src_mtime &&
115
+ tgt_mtime - src_mtime >= 60
116
+
117
+ Job.new(
118
+ program: r["Program"].to_s,
119
+ path: path,
120
+ description: r["Description"].to_s,
121
+ active: (r["Active"] || 0).to_i,
122
+ excludes: excludes,
123
+ label: r["Label"].to_s,
124
+ source: source,
125
+ target: target,
126
+ cmd: r["Cmd"].to_s,
127
+ sync_file: r["_note_file"].to_s,
128
+ source_exists: src_exists,
129
+ target_exists: tgt_exists,
130
+ source_mtime: src_mtime,
131
+ target_mtime: tgt_mtime,
132
+ conflict: !!conflict,
133
+ )
134
+ end
135
+
136
+ def stat(path)
137
+ st = File.stat(path)
138
+ [true, st.mtime]
139
+ rescue Errno::ENOENT, Errno::EACCES
140
+ [false, nil]
141
+ end
142
+ end
143
+ end
data/lib/twin/sync.rb ADDED
@@ -0,0 +1,68 @@
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, cmd_status = run(["sh", "-c", job.cmd])
48
+ output += "\ncmd: #{job.cmd}\n#{cmd_out}"
49
+ return [false, output] unless cmd_status.success?
50
+ end
51
+
52
+ [true, output]
53
+ end
54
+
55
+ # Sync all jobs in a Program. Returns array of [job, success, output].
56
+ def run_program(cfg, program, dry_run: false)
57
+ program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
58
+ end
59
+
60
+ def run(args)
61
+ require "open3"
62
+ stdout, stderr, status = Open3.capture3(*args)
63
+ [stdout + stderr, status]
64
+ rescue Errno::ENOENT
65
+ raise "command not found: #{args.first}"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module Twin
2
+ VERSION = "0.1.3"
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,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mark-twin
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
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/mark-twin
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ source_code_uri: https://github.com/rhsev/mark-twin
52
+ bug_tracker_uri: https://github.com/rhsev/mark-twin/issues
53
+ post_install_message: |2+
54
+
55
+ twin requires these external tools in your PATH:
56
+ grubber https://github.com/rhsev/grubber
57
+ rsync (preinstalled on macOS)
58
+ fzf brew install fzf
59
+
60
+ Optional for the preview pane:
61
+ apex https://github.com/ttscoff/apex
62
+ glow / bat as fallbacks (cat is used if none are present)
63
+
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '3.1'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 4.0.4
79
+ specification_version: 4
80
+ summary: Sync configuration folders between two Macs from self-documenting Markdown
81
+ files
82
+ test_files: []
83
+ ...