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 +7 -0
- data/ARCHITECTURE.md +138 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/bin/twin +7 -0
- data/lib/twin/cli.rb +244 -0
- data/lib/twin/config.rb +46 -0
- data/lib/twin/picker.rb +209 -0
- data/lib/twin/preview.rb +92 -0
- data/lib/twin/scanner.rb +141 -0
- data/lib/twin/sync.rb +67 -0
- data/lib/twin/version.rb +3 -0
- data/lib/twin.rb +7 -0
- metadata +71 -0
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
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"
|
data/lib/twin/config.rb
ADDED
|
@@ -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
|
data/lib/twin/picker.rb
ADDED
|
@@ -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
|
data/lib/twin/preview.rb
ADDED
|
@@ -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
|
data/lib/twin/scanner.rb
ADDED
|
@@ -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
|
data/lib/twin/version.rb
ADDED
data/lib/twin.rb
ADDED
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: []
|