mark-twin 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +57 -5
- data/README.md +101 -1
- data/bin/twin +5 -0
- data/lib/twin/add.rb +177 -0
- data/lib/twin/cli.rb +109 -20
- data/lib/twin/journal.rb +49 -0
- data/lib/twin/picker.rb +2 -0
- data/lib/twin/remote.rb +72 -0
- data/lib/twin/scanner.rb +41 -3
- data/lib/twin/sync.rb +49 -12
- data/lib/twin/version.rb +1 -1
- data/lib/twin.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a1efe3b9e90b401c9c6f8a25652db649ddea26054ede30f2c221d0d73246297
|
|
4
|
+
data.tar.gz: b85e21f28805ade66696c9997b986b0472f2d3028971075a30f15d35ede113b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf13dd6e51d53af11adc5b49c5e183ab6dd07ec9a49e4d83cafa60c712ca2bb492bb944f00b1afca864e051c09f7c054465d725d1da858d0960c48f6507778ff
|
|
7
|
+
data.tar.gz: d7a00b682a1e8b833c7aceda323da4c69625fa3538def0810334dd1ed757696aca7d92d34d1e914bb06b60d1539ed6b990f5b24fdb94a59847bee2404272e51e
|
data/ARCHITECTURE.md
CHANGED
|
@@ -27,10 +27,13 @@ sync-files (.md)
|
|
|
27
27
|
```
|
|
28
28
|
lib/twin/
|
|
29
29
|
version.rb
|
|
30
|
+
remote.rb ssh targets: detection, reachability, batched stat, mkdir
|
|
30
31
|
template.rb {{token}} substitution + render-file helper
|
|
31
32
|
config.rb ~/.config/twin/config.yaml loader; host table → var_map
|
|
32
33
|
scanner.rb Job, Program structs; grubber + template + stat → grouped Programs
|
|
33
34
|
sync.rb rsync / render execution, mount check, post-sync hook
|
|
35
|
+
journal.rb append-only sync journal (~/.local/state/twin/log.jsonl)
|
|
36
|
+
add.rb `twin add` — interactive scaffolding of new sync entries
|
|
34
37
|
picker.rb fzf wrapper with apex preview
|
|
35
38
|
cli.rb subcommand dispatcher
|
|
36
39
|
|
|
@@ -48,8 +51,8 @@ delete, render, render_outdated, target_path_field, sync_file,
|
|
|
48
51
|
source_exists, target_exists, source_mtime, target_mtime, conflict
|
|
49
52
|
```
|
|
50
53
|
|
|
51
|
-
`Job#status` → one of `disabled /
|
|
52
|
-
target_newer / in_sync / source_newer`. Render jobs derive status from content
|
|
54
|
+
`Job#status` → one of `disabled / unreachable / both_missing / missing_source /
|
|
55
|
+
missing_target / target_newer / in_sync / source_newer`. Render jobs derive status from content
|
|
53
56
|
(`render_outdated`), not mtime; non-render jobs ignore those fields.
|
|
54
57
|
`Job#target_path` joins `target` with `target_path_field || path`.
|
|
55
58
|
|
|
@@ -151,17 +154,60 @@ File argument resolution (`twin <arg>` and `--file=<arg>`):
|
|
|
151
154
|
Unknown options (anything starting with `-` that isn't `--help`) print an
|
|
152
155
|
error pointing at `twin --help` and exit 1.
|
|
153
156
|
|
|
157
|
+
`twin sync` returns exit 1 when any job failed. `--quiet` suppresses output
|
|
158
|
+
for successful no-op jobs (conflicts, errors and real transfers still print);
|
|
159
|
+
`--skip-unavailable` skips unmounted/unreachable targets instead of aborting.
|
|
160
|
+
The combination is the unattended-run mode (launchd/cron).
|
|
161
|
+
|
|
162
|
+
Every non-dry-run job lands in the journal (`Journal.record`): one JSON line
|
|
163
|
+
in `~/.local/state/twin/log.jsonl` (`TWIN_STATE_DIR` overrides the directory)
|
|
164
|
+
with timestamp, program, path, target, `ok`, `changed`, and a truncated error
|
|
165
|
+
line on failure. `twin log [-n N] [--json]` reads it back. Journal write
|
|
166
|
+
failures warn once and never break a sync.
|
|
167
|
+
|
|
168
|
+
`twin add <path>` (`add.rb`) scaffolds a new entry: it matches the expanded
|
|
169
|
+
path against the token-substituted `Source:` frontmatter of every sync-file
|
|
170
|
+
(files with no or foreign roots drop out), computes `Path:` relative to the
|
|
171
|
+
chosen root, suggests excludes from a fixed list of generated/heavy dirs found
|
|
172
|
+
in the source (`SUGGEST_EXCLUDES`), rejects paths the file already has a
|
|
173
|
+
block for, and appends heading + prose stub + YAML block. With no covering
|
|
174
|
+
sync-file it can create one (frontmatter from prompts). The pure helpers
|
|
175
|
+
(`frontmatter`, `candidates`, `relative_path`, `suggest_excludes`,
|
|
176
|
+
`build_block`) are unit-tested; the prompt flow reads plain stdin, so it is
|
|
177
|
+
scriptable by piping answers.
|
|
178
|
+
|
|
154
179
|
`twin doctor` checks required tools (grubber, rsync, fzf), optional renderers
|
|
155
180
|
(apex, glow, bat), templating (host/target resolve, every `{{token}}` resolves),
|
|
156
181
|
and whether all configured sync targets are mounted. Exits 1 if any required
|
|
157
182
|
check fails.
|
|
158
183
|
|
|
184
|
+
## Remote targets
|
|
185
|
+
|
|
186
|
+
`Target: user@host:/path` (rsync notation; colon before the first slash) makes
|
|
187
|
+
a job remote — `Job#remote?`. Sources stay local, twin pushes.
|
|
188
|
+
|
|
189
|
+
- **Stat**: remote paths can't be `File.stat`ed, so `build_job` leaves them
|
|
190
|
+
"missing" and `Scanner.fill_remote_stats` fills them in afterwards — one
|
|
191
|
+
`ssh` round-trip per host for all its paths (`Remote.stat_paths`: paths over
|
|
192
|
+
stdin, `path\tepoch` back; BSD `stat -f %m` with GNU `stat -c %Y` fallback).
|
|
193
|
+
A failed ssh sets `target_unreachable` → status `:unreachable`; the scan
|
|
194
|
+
itself never fails on a dead host.
|
|
195
|
+
- **Reachability** replaces the mount check (`Remote.reachable?`,
|
|
196
|
+
`ssh -o BatchMode=yes … true` — key auth only, never prompts).
|
|
197
|
+
- **rsync** needs no changes: the target string is already in its remote
|
|
198
|
+
syntax. Parent directories are created via `ssh host mkdir -p` first.
|
|
199
|
+
- **`Cmd`** still runs locally (`sh -c`); acting on the server means writing
|
|
200
|
+
an `ssh host '…'` command in the sync-file.
|
|
201
|
+
- **`Render: true` + remote raises** at scan time — render reads/writes target
|
|
202
|
+
content, which twin only does on local (mounted) paths.
|
|
203
|
+
|
|
159
204
|
## Sync
|
|
160
205
|
|
|
161
206
|
Before syncing:
|
|
162
207
|
|
|
163
|
-
1. **Mount check** — every unique target root must be a mount point
|
|
164
|
-
(`File.stat.dev != parent.dev`)
|
|
208
|
+
1. **Mount check** — every unique local target root must be a mount point
|
|
209
|
+
(`File.stat.dev != parent.dev`); remote targets must be ssh-reachable.
|
|
210
|
+
Aborts otherwise.
|
|
165
211
|
2. **Conflict warning** — emits stderr listing jobs where the target is
|
|
166
212
|
newer than the source. Continues anyway (`rsync --update` skips them).
|
|
167
213
|
|
|
@@ -171,7 +217,13 @@ Then per Job, **rsync path** (non-render):
|
|
|
171
217
|
rsync -av --itemize-changes --update [--delete] [--exclude=...]* src/ tgt/
|
|
172
218
|
```
|
|
173
219
|
|
|
174
|
-
`--delete` is added when the Job has `delete: true` (from `Delete: true`)
|
|
220
|
+
`--delete` is added when the Job has `delete: true` (from `Delete: true`),
|
|
221
|
+
together with `--backup --backup-dir=<target>/.twin-backup/<run-stamp>` —
|
|
222
|
+
deleted and overwritten files are moved aside, not destroyed. The stamp is
|
|
223
|
+
per-process, so one run shares a backup dir; rsync only creates it when it
|
|
224
|
+
actually backs something up. `--exclude=.twin-backup/` protects the backup
|
|
225
|
+
dir from a `Path: "."` sync deleting it. For remote targets the backup dir
|
|
226
|
+
is the path part of the target (it lives on the receiving side).
|
|
175
227
|
`--itemize-changes` makes change detection deterministic: `Sync.transferred?`
|
|
176
228
|
matches itemize lines (`/\A[<>ch*][fdLDS]/` — `>f…`, `cd…`, `*deleting`),
|
|
177
229
|
covering files, directories and deletions, with no scraping of rsync's prose.
|
data/README.md
CHANGED
|
@@ -119,10 +119,58 @@ twin status # listing with source/target mtimes
|
|
|
119
119
|
twin sync -p grubber # sync one program by name pattern
|
|
120
120
|
twin sync --file=repos # sync all programs from a sync-file
|
|
121
121
|
twin sync --dry-run # preview without writing
|
|
122
|
+
twin log # recent journal entries (-n N, --json)
|
|
122
123
|
twin doctor # check tools, renderers, and sync targets
|
|
123
124
|
twin --help # show usage
|
|
124
125
|
```
|
|
125
126
|
|
|
127
|
+
### Adding a sync entry
|
|
128
|
+
|
|
129
|
+
`twin add <path>` scaffolds a new entry interactively, so the judgment calls
|
|
130
|
+
of setting up a sync become prompts with defaults:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
$ twin add ~/.config/fish
|
|
134
|
+
sync-file: home_macbook.md (/Users/admin → /Volumes/macbook/Users/admin)
|
|
135
|
+
Program name [fish]:
|
|
136
|
+
Why is this synced? (one line of prose): Shell config incl. abbreviations.
|
|
137
|
+
Description (short, for listings) [fish]: Fish Shell configuration
|
|
138
|
+
Exclude (comma-separated) [.git/]:
|
|
139
|
+
Mirror deletions on target (Delete: true)? (y/N) [n]:
|
|
140
|
+
Post-sync Cmd (empty for none):
|
|
141
|
+
|
|
142
|
+
added "fish" to home_macbook.md
|
|
143
|
+
Run a dry-run now? (Y/n) [y]:
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
twin matches the path against the `Source:` roots of your sync-files (asking
|
|
147
|
+
which to use when several match), derives `Path:` relative to that root,
|
|
148
|
+
suggests excludes for what it finds in the directory (`.git/`,
|
|
149
|
+
`node_modules/`, `.venv/`, …), refuses duplicates, and appends a Markdown
|
|
150
|
+
block — prose stub included. If no sync-file covers the path, it offers to
|
|
151
|
+
create one (frontmatter and all), which is also the quickest way to start
|
|
152
|
+
syncing to a new SSH target.
|
|
153
|
+
|
|
154
|
+
Every synced job is journaled to `~/.local/state/twin/log.jsonl` (one JSON
|
|
155
|
+
line per job: timestamp, program, path, outcome). `twin log` shows the recent
|
|
156
|
+
history; `twin sync` exits non-zero when any job failed.
|
|
157
|
+
|
|
158
|
+
### Unattended syncs
|
|
159
|
+
|
|
160
|
+
For a scheduled run (launchd, cron), combine two flags:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
twin sync --quiet --skip-unavailable
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`--quiet` prints only conflicts, errors, and jobs that actually changed
|
|
167
|
+
something — a no-op run is silent. `--skip-unavailable` skips targets that
|
|
168
|
+
are currently unmounted or unreachable instead of aborting, so a laptop that
|
|
169
|
+
isn't docked doesn't turn into an error. Combined, the run produces output
|
|
170
|
+
(and a non-zero exit) only when something genuinely needs attention, which is
|
|
171
|
+
exactly what launchd's stdout/stderr logging wants; the journal still records
|
|
172
|
+
every job.
|
|
173
|
+
|
|
126
174
|
File argument resolution:
|
|
127
175
|
|
|
128
176
|
- bare name (no `/`) → looked up by substring in `sync_dir`
|
|
@@ -192,7 +240,55 @@ transferred bytes; no-op syncs skip it. See the Helix entry in
|
|
|
192
240
|
|
|
193
241
|
The optional `Delete: true` field adds `--delete` to the rsync invocation,
|
|
194
242
|
so files removed from the source are also removed on the target. Useful for
|
|
195
|
-
directory syncs where the target should mirror the source exactly.
|
|
243
|
+
directory syncs where the target should mirror the source exactly. As a
|
|
244
|
+
safety net, deleted and overwritten files are moved to a per-run backup
|
|
245
|
+
directory on the target (`<target>/.twin-backup/<timestamp>/`) instead of
|
|
246
|
+
being destroyed — prune it occasionally.
|
|
247
|
+
|
|
248
|
+
## SSH targets
|
|
249
|
+
|
|
250
|
+
`Target:` accepts remote destinations in rsync notation — `user@host:/path` or
|
|
251
|
+
`host:/path`. Everything else stays the same: the YAML block, excludes,
|
|
252
|
+
`Delete:`, the `Cmd` hook.
|
|
253
|
+
|
|
254
|
+
````markdown
|
|
255
|
+
---
|
|
256
|
+
Active: 1
|
|
257
|
+
Label: mini → server
|
|
258
|
+
Source: /Volumes/lightning/Git/Website
|
|
259
|
+
Target: ralf@server:/srv/www
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Website
|
|
263
|
+
|
|
264
|
+
Static site, deployed straight from the build directory.
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
Program: website
|
|
268
|
+
Path: public
|
|
269
|
+
Description: static site
|
|
270
|
+
Exclude: .git/
|
|
271
|
+
Cmd: ssh ralf@server 'sudo systemctl reload caddy'
|
|
272
|
+
```
|
|
273
|
+
````
|
|
274
|
+
|
|
275
|
+
Details:
|
|
276
|
+
|
|
277
|
+
- **Push only.** `Source:` stays local; twin syncs *to* the remote host.
|
|
278
|
+
- **Key-based auth required.** twin probes and stats hosts with
|
|
279
|
+
`ssh -o BatchMode=yes`, which never prompts for a password. Set up an SSH
|
|
280
|
+
key (`ssh-copy-id host`) first; `twin doctor` shows whether a host is
|
|
281
|
+
reachable.
|
|
282
|
+
- **Status works remotely.** `twin status` and the picker stat all remote
|
|
283
|
+
paths of a host in a single ssh round-trip (macOS and Linux targets both
|
|
284
|
+
supported). An unreachable host shows as `?` instead of failing the scan.
|
|
285
|
+
- **The mount check becomes a reachability check** — sync aborts if the host
|
|
286
|
+
doesn't answer.
|
|
287
|
+
- **`Cmd` runs locally**, exactly as for mounted targets. To act on the
|
|
288
|
+
server, make the command an `ssh host '…'` call (see example above).
|
|
289
|
+
- **`Render: true` is not supported** for remote targets (twin would have to
|
|
290
|
+
read and write remote file contents). Render locally or keep rendered files
|
|
291
|
+
on mounted targets.
|
|
196
292
|
|
|
197
293
|
## Templating
|
|
198
294
|
|
|
@@ -265,3 +361,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
|
|
|
265
361
|
```bash
|
|
266
362
|
rake test
|
|
267
363
|
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
*Part of a family of plain-text tools — the [profile page](https://github.com/rhsev) has the map.*
|
data/bin/twin
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
+
# Sync-files are UTF-8 regardless of the environment's locale — without this,
|
|
4
|
+
# a launchd/cron context (no LANG) reads them as US-ASCII and chokes on "→".
|
|
5
|
+
Encoding.default_external = Encoding::UTF_8
|
|
6
|
+
Encoding.default_internal = Encoding::UTF_8
|
|
7
|
+
|
|
3
8
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
4
9
|
|
|
5
10
|
require "twin"
|
data/lib/twin/add.rb
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
require_relative "template"
|
|
4
|
+
|
|
5
|
+
module Twin
|
|
6
|
+
# `twin add <path>` — guided scaffolding of a new sync entry. Turns the
|
|
7
|
+
# judgment calls of the "add a sync" recipe (which sync-file? which Path?
|
|
8
|
+
# what to exclude? deploy hook?) into prompts with sensible defaults, then
|
|
9
|
+
# appends a Markdown block to the chosen sync-file.
|
|
10
|
+
module Add
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Directories that are usually machine-generated or heavy — offered as
|
|
14
|
+
# exclude defaults when present in the source directory.
|
|
15
|
+
SUGGEST_EXCLUDES = %w[.git node_modules .venv __pycache__ dist build target].freeze
|
|
16
|
+
|
|
17
|
+
# ── pure helpers (unit-tested) ────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
# Frontmatter of a sync-file as a Hash (Source/Target token-substituted),
|
|
20
|
+
# or nil when the file has none / it isn't a Hash.
|
|
21
|
+
def frontmatter(file, vars = {})
|
|
22
|
+
text = File.read(file)
|
|
23
|
+
return nil unless text.start_with?("---\n")
|
|
24
|
+
body = text[4..].split(/^---\s*$/, 2).first
|
|
25
|
+
data = begin
|
|
26
|
+
YAML.safe_load(body.to_s)
|
|
27
|
+
rescue Psych::SyntaxError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
return nil unless data.is_a?(Hash)
|
|
31
|
+
%w[Source Target].each do |k|
|
|
32
|
+
next unless data[k].is_a?(String)
|
|
33
|
+
data[k] = Twin::Template.substitute(data[k], vars, context: File.basename(file))
|
|
34
|
+
end
|
|
35
|
+
data
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sync-files in dir whose Source is an ancestor of path.
|
|
41
|
+
# Returns [[file, frontmatter], …].
|
|
42
|
+
def candidates(dir, path, vars = {})
|
|
43
|
+
Dir.glob(File.join(dir, "*.md")).sort.filter_map do |f|
|
|
44
|
+
fm = frontmatter(f, vars)
|
|
45
|
+
next unless fm && fm["Source"].is_a?(String) && !fm["Source"].empty?
|
|
46
|
+
root = File.expand_path(fm["Source"])
|
|
47
|
+
next unless path == root || path.start_with?(root + "/")
|
|
48
|
+
[f, fm]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def relative_path(root, path)
|
|
53
|
+
path == root ? "." : path[(root.length + 1)..]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def suggest_excludes(path)
|
|
57
|
+
return [] unless File.directory?(path)
|
|
58
|
+
SUGGEST_EXCLUDES.filter_map do |e|
|
|
59
|
+
full = File.join(path, e)
|
|
60
|
+
next unless File.exist?(full)
|
|
61
|
+
File.directory?(full) ? "#{e}/" : e
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_block(program:, path:, description: "", excludes: [], delete: false, cmd: "", prose: "")
|
|
66
|
+
yaml = ["Program: #{program}", "Path: #{path}"]
|
|
67
|
+
yaml << "Description: #{description}" unless description.empty?
|
|
68
|
+
yaml << "Exclude: #{excludes.join(',')}" unless excludes.empty?
|
|
69
|
+
yaml << "Delete: true" if delete
|
|
70
|
+
yaml << "Cmd: #{cmd}" unless cmd.empty?
|
|
71
|
+
prose = "TODO: document why this path is synced." if prose.empty?
|
|
72
|
+
"\n## #{program}\n\n#{prose}\n\n```yaml\n#{yaml.join("\n")}\n```\n"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def frontmatter_text(source:, target:, label: "")
|
|
76
|
+
lines = ["---", "Active: 1"]
|
|
77
|
+
lines << "Label: #{label}" unless label.empty?
|
|
78
|
+
lines << "Source: #{source}" << "Target: #{target}" << "---" << ""
|
|
79
|
+
lines.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── interactive flow ──────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
# Returns {program:, file:, dry_run:} on success (dry_run: whether the
|
|
85
|
+
# user asked for one), nil when nothing was written.
|
|
86
|
+
def run(cfg, args)
|
|
87
|
+
raw = args.first
|
|
88
|
+
raise "usage: twin add <path>" if raw.nil? || raw.empty?
|
|
89
|
+
path = File.expand_path(raw)
|
|
90
|
+
raise "not found: #{path}" unless File.exist?(path)
|
|
91
|
+
|
|
92
|
+
vars = cfg.var_map
|
|
93
|
+
picked = pick_sync_file(cfg, path, vars)
|
|
94
|
+
return nil unless picked
|
|
95
|
+
file, fm = picked
|
|
96
|
+
|
|
97
|
+
root = File.expand_path(fm["Source"])
|
|
98
|
+
rel = relative_path(root, path)
|
|
99
|
+
|
|
100
|
+
if File.exist?(file) && File.read(file).match?(/^Path:\s*#{Regexp.escape(rel)}\s*$/)
|
|
101
|
+
raise "#{File.basename(file)} already has a block with Path: #{rel}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
program = ask("Program name", File.basename(path))
|
|
105
|
+
prose = ask("Why is this synced? (one line of prose)")
|
|
106
|
+
desc = ask("Description (short, for listings)", program)
|
|
107
|
+
excl = ask("Exclude (comma-separated)", suggest_excludes(path).join(","))
|
|
108
|
+
.split(",").map(&:strip).reject(&:empty?)
|
|
109
|
+
delete = yes?(ask("Mirror deletions on target (Delete: true)? (y/N)", "n"))
|
|
110
|
+
cmd = ask("Post-sync Cmd (empty for none)")
|
|
111
|
+
|
|
112
|
+
block = build_block(program: program, path: rel, description: desc,
|
|
113
|
+
excludes: excl, delete: delete, cmd: cmd, prose: prose)
|
|
114
|
+
File.open(file, "a") { |f| f.write(block) }
|
|
115
|
+
|
|
116
|
+
puts "\nadded #{program.inspect} to #{File.basename(file)}"
|
|
117
|
+
puts " #{File.join(root, rel)} → #{File.join(fm['Target'].to_s, rel)}"
|
|
118
|
+
|
|
119
|
+
dry = yes?(ask("Run a dry-run now? (Y/n)", "y"))
|
|
120
|
+
{ program: program, file: file, dry_run: dry }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Choose (or create) the sync-file covering path.
|
|
124
|
+
# Returns [file, frontmatter] or nil.
|
|
125
|
+
def pick_sync_file(cfg, path, vars)
|
|
126
|
+
cands = candidates(cfg.sync_dir, path, vars)
|
|
127
|
+
case cands.size
|
|
128
|
+
when 0 then offer_new_sync_file(cfg, path, vars)
|
|
129
|
+
when 1
|
|
130
|
+
file, fm = cands.first
|
|
131
|
+
puts "sync-file: #{File.basename(file)} (#{fm['Source']} → #{fm['Target']})"
|
|
132
|
+
cands.first
|
|
133
|
+
else
|
|
134
|
+
puts "multiple sync-files cover #{path}:"
|
|
135
|
+
cands.each_with_index do |(f, fm), i|
|
|
136
|
+
puts " #{i + 1}) #{File.basename(f)} (#{fm['Source']} → #{fm['Target']})"
|
|
137
|
+
end
|
|
138
|
+
n = ask("Which one?", "1").to_i
|
|
139
|
+
cands[n - 1] or raise "invalid choice: #{n}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def offer_new_sync_file(cfg, path, vars)
|
|
144
|
+
puts "no sync-file in #{cfg.sync_dir} covers #{path}"
|
|
145
|
+
return nil unless yes?(ask("Create a new sync-file? (y/N)", "n"))
|
|
146
|
+
|
|
147
|
+
name = ask("File name", "#{File.basename(path).sub(/\A\./, '')}.md")
|
|
148
|
+
name += ".md" unless name.end_with?(".md")
|
|
149
|
+
file = File.join(cfg.sync_dir, name)
|
|
150
|
+
raise "already exists: #{file}" if File.exist?(file)
|
|
151
|
+
|
|
152
|
+
source = ask("Source base on this machine", File.dirname(path))
|
|
153
|
+
target = ask("Target base (mount path or user@host:/path)")
|
|
154
|
+
raise "Target is required" if target.empty?
|
|
155
|
+
label = ask("Label (e.g. mini → server)")
|
|
156
|
+
|
|
157
|
+
File.write(file, frontmatter_text(source: source, target: target, label: label))
|
|
158
|
+
puts "created #{File.basename(file)}"
|
|
159
|
+
fm = { "Source" => source, "Target" => target }
|
|
160
|
+
%w[Source Target].each do |k|
|
|
161
|
+
fm[k] = Twin::Template.substitute(fm[k], vars, context: name)
|
|
162
|
+
end
|
|
163
|
+
[file, fm]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def ask(prompt, default = "")
|
|
167
|
+
print default.empty? ? "#{prompt}: " : "#{prompt} [#{default}]: "
|
|
168
|
+
$stdout.flush
|
|
169
|
+
ans = $stdin.gets
|
|
170
|
+
raise "aborted (stdin closed)" if ans.nil?
|
|
171
|
+
ans = ans.strip
|
|
172
|
+
ans.empty? ? default : ans
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def yes?(answer) = answer.match?(/\Ay/i)
|
|
176
|
+
end
|
|
177
|
+
end
|
data/lib/twin/cli.rb
CHANGED
|
@@ -6,6 +6,8 @@ require "time" # Time#iso8601 for --json output
|
|
|
6
6
|
require_relative "config"
|
|
7
7
|
require_relative "scanner"
|
|
8
8
|
require_relative "sync"
|
|
9
|
+
require_relative "journal"
|
|
10
|
+
require_relative "add"
|
|
9
11
|
require_relative "picker"
|
|
10
12
|
|
|
11
13
|
module Twin
|
|
@@ -22,6 +24,9 @@ module Twin
|
|
|
22
24
|
twin list [--all] [--label X] [--file X] [--json]
|
|
23
25
|
twin status [--all] [--label X] [--file X] [--json]
|
|
24
26
|
twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
|
|
27
|
+
[--quiet] [--skip-unavailable]
|
|
28
|
+
twin add <path> scaffold a new sync entry for a local path
|
|
29
|
+
twin log [-n N] [--json] recent journal entries (default 20)
|
|
25
30
|
twin doctor check tools, renderers, and sync targets
|
|
26
31
|
twin --help show this message
|
|
27
32
|
|
|
@@ -46,6 +51,8 @@ module Twin
|
|
|
46
51
|
when "list" then cmd_list(cfg, argv.drop(1))
|
|
47
52
|
when "status" then cmd_status(cfg, argv.drop(1))
|
|
48
53
|
when "sync" then cmd_sync(cfg, argv.drop(1))
|
|
54
|
+
when "add" then cmd_add(cfg, argv.drop(1))
|
|
55
|
+
when "log" then cmd_log(argv.drop(1))
|
|
49
56
|
when "doctor" then cmd_doctor(cfg)
|
|
50
57
|
when "-h", "--help", "help"
|
|
51
58
|
puts USAGE
|
|
@@ -137,6 +144,7 @@ module Twin
|
|
|
137
144
|
p.jobs.each do |j|
|
|
138
145
|
src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
139
146
|
tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
147
|
+
tgt = "(unreachable)" if j.target_unreachable
|
|
140
148
|
conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
|
|
141
149
|
puts " #{j.path}#{conflict}"
|
|
142
150
|
puts " src #{src}"
|
|
@@ -156,31 +164,40 @@ module Twin
|
|
|
156
164
|
end
|
|
157
165
|
|
|
158
166
|
if programs.empty?
|
|
159
|
-
puts "no matching programs"
|
|
167
|
+
puts "no matching programs" unless opts[:quiet]
|
|
160
168
|
return
|
|
161
169
|
end
|
|
162
170
|
|
|
163
|
-
programs.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
results = programs.map do |p|
|
|
172
|
+
sync_jobs(cfg, p, p.active_jobs,
|
|
173
|
+
dry_run: opts[:dry_run], quiet: opts[:quiet],
|
|
174
|
+
skip_unavailable: opts[:skip_unavailable])
|
|
175
|
+
end
|
|
176
|
+
exit 1 unless results.all?
|
|
168
177
|
end
|
|
169
178
|
|
|
170
|
-
|
|
179
|
+
# Sync the given jobs. Returns true when every attempted job succeeded.
|
|
180
|
+
# quiet: print only conflicts, errors, and jobs that changed something
|
|
181
|
+
# skip_unavailable: skip jobs whose target is unmounted/unreachable instead of aborting
|
|
182
|
+
def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false)
|
|
171
183
|
jobs = jobs.select { |j| j.active == 1 }
|
|
172
|
-
return if jobs.empty?
|
|
173
|
-
|
|
174
|
-
# one
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
return true if jobs.empty?
|
|
185
|
+
|
|
186
|
+
# one availability check per unique target root:
|
|
187
|
+
# local targets must be mounted volumes, remote ones reachable via ssh
|
|
188
|
+
availability = {}
|
|
189
|
+
jobs.each { |j| availability[j.target] ||= target_availability(j) }
|
|
190
|
+
jobs, unavailable = jobs.partition { |j| availability[j.target] == :ok }
|
|
191
|
+
|
|
192
|
+
unavailable.map { |j| availability[j.target] }.uniq.each do |reason|
|
|
193
|
+
if skip_unavailable
|
|
194
|
+
puts "skipped: #{reason}" unless quiet
|
|
195
|
+
else
|
|
196
|
+
warn "abort: #{reason}"
|
|
180
197
|
exit 1
|
|
181
198
|
end
|
|
182
|
-
checked << j.target
|
|
183
199
|
end
|
|
200
|
+
return true if jobs.empty?
|
|
184
201
|
|
|
185
202
|
conflicts = jobs.select(&:conflict)
|
|
186
203
|
unless conflicts.empty?
|
|
@@ -189,13 +206,74 @@ module Twin
|
|
|
189
206
|
warn "continuing sync (--update skips newer files on target)."
|
|
190
207
|
end
|
|
191
208
|
|
|
192
|
-
|
|
209
|
+
header_printed = false
|
|
210
|
+
all_ok = true
|
|
193
211
|
jobs.each do |job|
|
|
194
|
-
success, output, = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
|
|
212
|
+
success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
|
|
213
|
+
Twin::Journal.record(job, success: success, transferred: transferred, output: output) unless dry_run
|
|
214
|
+
all_ok &&= success
|
|
215
|
+
next if quiet && success && !transferred
|
|
216
|
+
|
|
217
|
+
unless header_printed
|
|
218
|
+
puts "→ #{program.name}"
|
|
219
|
+
header_printed = true
|
|
220
|
+
end
|
|
195
221
|
puts " • #{job.path}"
|
|
196
222
|
puts output.gsub(/^/, " ") if output && !output.strip.empty?
|
|
197
223
|
warn " error syncing #{job.path}" unless success
|
|
198
224
|
end
|
|
225
|
+
all_ok
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# :ok, or a human-readable reason the target can't be synced right now.
|
|
229
|
+
def target_availability(job)
|
|
230
|
+
if job.remote?
|
|
231
|
+
host, = Twin::Remote.split(job.target)
|
|
232
|
+
return :ok if Twin::Remote.reachable?(host)
|
|
233
|
+
"#{host} is not reachable via ssh"
|
|
234
|
+
else
|
|
235
|
+
return :ok if Twin::Sync.mounted?(job.target)
|
|
236
|
+
"#{job.target} is not a mounted volume"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ── add ────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def cmd_add(cfg, args)
|
|
243
|
+
result = Twin::Add.run(cfg, args)
|
|
244
|
+
return unless result && result[:dry_run]
|
|
245
|
+
cmd_sync(cfg, ["-p", result[:program],
|
|
246
|
+
"--file=#{File.basename(result[:file])}", "--dry-run"])
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# ── log ────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def cmd_log(args)
|
|
252
|
+
n = 20
|
|
253
|
+
json = false
|
|
254
|
+
OptionParser.new do |o|
|
|
255
|
+
o.on("-n N", Integer) { |v| n = v }
|
|
256
|
+
o.on("--json") { json = true }
|
|
257
|
+
end.parse!(args)
|
|
258
|
+
|
|
259
|
+
entries = Twin::Journal.tail(n)
|
|
260
|
+
if json
|
|
261
|
+
puts JSON.pretty_generate(entries)
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
if entries.empty?
|
|
265
|
+
puts "journal is empty (#{Twin::Journal.log_path})"
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
tty = $stdout.tty?
|
|
270
|
+
entries.each do |e|
|
|
271
|
+
ts = Time.parse(e["ts"]).strftime("%Y-%m-%d %H:%M:%S")
|
|
272
|
+
mark = e["ok"] ? "✓" : "✗"
|
|
273
|
+
mark = Picker.colorize(e["ok"] ? :in_sync : :both_missing, mark) if tty
|
|
274
|
+
note = e["ok"] ? (e["changed"] ? "changed" : "no-op") : "error: #{e["error"]}"
|
|
275
|
+
puts "#{ts} #{mark} #{e["program"]} #{e["path"]} (#{note})"
|
|
276
|
+
end
|
|
199
277
|
end
|
|
200
278
|
|
|
201
279
|
# ── doctor ─────────────────────────────────────────────────────────────────
|
|
@@ -252,7 +330,15 @@ module Twin
|
|
|
252
330
|
puts " (no programs loaded)"
|
|
253
331
|
else
|
|
254
332
|
targets.each do |tgt|
|
|
255
|
-
if Twin::
|
|
333
|
+
if Twin::Remote.remote?(tgt)
|
|
334
|
+
host, = Twin::Remote.split(tgt)
|
|
335
|
+
if Twin::Remote.reachable?(host)
|
|
336
|
+
puts " ✓ #{tgt} (ssh)"
|
|
337
|
+
else
|
|
338
|
+
puts " ✗ #{tgt} (ssh: #{host} not reachable)"
|
|
339
|
+
ok = false
|
|
340
|
+
end
|
|
341
|
+
elsif Twin::Sync.mounted?(tgt)
|
|
256
342
|
puts " ✓ #{tgt}"
|
|
257
343
|
else
|
|
258
344
|
puts " ✗ #{tgt} (not mounted)"
|
|
@@ -287,13 +373,16 @@ module Twin
|
|
|
287
373
|
end
|
|
288
374
|
|
|
289
375
|
def parse_sync_opts(args)
|
|
290
|
-
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false
|
|
376
|
+
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
|
|
377
|
+
quiet: false, skip_unavailable: false }
|
|
291
378
|
OptionParser.new do |o|
|
|
292
379
|
o.on("--all") { opts[:show_all] = true }
|
|
293
380
|
o.on("--label=L") { |v| opts[:label] = v }
|
|
294
381
|
o.on("--file=F") { |v| opts[:file] = v }
|
|
295
382
|
o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
|
|
296
383
|
o.on("--dry-run") { opts[:dry_run] = true }
|
|
384
|
+
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
385
|
+
o.on("--skip-unavailable") { opts[:skip_unavailable] = true }
|
|
297
386
|
end.parse!(args)
|
|
298
387
|
opts
|
|
299
388
|
end
|
data/lib/twin/journal.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Twin
|
|
6
|
+
# Append-only sync journal: one JSON line per synced job in
|
|
7
|
+
# ~/.local/state/twin/log.jsonl. Answers "did yesterday's sync actually
|
|
8
|
+
# run, and what did it do?" — and stays machine-readable (jq/grubber).
|
|
9
|
+
# Journal failures never break a sync; they degrade to a warning.
|
|
10
|
+
module Journal
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def state_dir
|
|
14
|
+
ENV["TWIN_STATE_DIR"] || File.join(Dir.home, ".local", "state", "twin")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log_path = File.join(state_dir, "log.jsonl")
|
|
18
|
+
|
|
19
|
+
# Record one job result. Dry-runs are not journaled.
|
|
20
|
+
def record(job, success:, transferred:, output: nil)
|
|
21
|
+
entry = {
|
|
22
|
+
ts: Time.now.iso8601,
|
|
23
|
+
program: job.program,
|
|
24
|
+
path: job.path,
|
|
25
|
+
target: job.target,
|
|
26
|
+
ok: success,
|
|
27
|
+
changed: transferred,
|
|
28
|
+
}
|
|
29
|
+
unless success
|
|
30
|
+
entry[:error] = output.to_s.lines.map(&:strip).reject(&:empty?).last.to_s[0, 200]
|
|
31
|
+
end
|
|
32
|
+
FileUtils.mkdir_p(state_dir)
|
|
33
|
+
File.open(log_path, "a") { |f| f.puts(JSON.generate(entry)) }
|
|
34
|
+
rescue SystemCallError => e
|
|
35
|
+
warn "journal: #{e.message}" unless @warned
|
|
36
|
+
@warned = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Last n entries, oldest first. Unparseable lines are skipped.
|
|
40
|
+
def tail(n)
|
|
41
|
+
return [] unless File.exist?(log_path)
|
|
42
|
+
File.readlines(log_path).last(n).filter_map do |line|
|
|
43
|
+
JSON.parse(line)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/twin/picker.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Twin
|
|
|
17
17
|
missing_target: "!",
|
|
18
18
|
missing_source: "!",
|
|
19
19
|
both_missing: "✗",
|
|
20
|
+
unreachable: "?",
|
|
20
21
|
disabled: "·",
|
|
21
22
|
}.freeze
|
|
22
23
|
|
|
@@ -27,6 +28,7 @@ module Twin
|
|
|
27
28
|
missing_target: "\e[31m", # red
|
|
28
29
|
missing_source: "\e[31m", # red
|
|
29
30
|
both_missing: "\e[31m", # red
|
|
31
|
+
unreachable: "\e[31m", # red
|
|
30
32
|
disabled: "\e[2m", # dim
|
|
31
33
|
}.freeze
|
|
32
34
|
|
data/lib/twin/remote.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Twin
|
|
4
|
+
# Remote (ssh) targets, written exactly as rsync understands them:
|
|
5
|
+
# "user@host:/path" or "host:/path". A target counts as remote when a colon
|
|
6
|
+
# appears before the first slash. Sources stay local — twin pushes.
|
|
7
|
+
module Remote
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"].freeze
|
|
11
|
+
|
|
12
|
+
def remote?(target)
|
|
13
|
+
%r{\A[^/]+:}.match?(target.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# "user@host:/path" → ["user@host", "/path"]
|
|
17
|
+
def split(target)
|
|
18
|
+
host, path = target.to_s.split(":", 2)
|
|
19
|
+
[host, path.to_s]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Non-interactive reachability probe (BatchMode: never asks for a password).
|
|
23
|
+
def reachable?(host)
|
|
24
|
+
system("ssh", *SSH_OPTS, host, "true", out: File::NULL, err: File::NULL)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Stat many paths in one ssh round-trip. Paths go over stdin (one per
|
|
28
|
+
# line), the remote loop answers "path<TAB>epoch" or "path<TAB>-" for
|
|
29
|
+
# missing ones. Tries BSD stat first, then GNU — covers macOS and Linux.
|
|
30
|
+
# Returns {path => Time or nil-if-missing}, or nil when ssh itself failed.
|
|
31
|
+
STAT_SCRIPT = <<~SH.freeze
|
|
32
|
+
while IFS= read -r p; do
|
|
33
|
+
if [ -e "$p" ]; then
|
|
34
|
+
printf '%s\t%s\n' "$p" "$(stat -f %m -- "$p" 2>/dev/null || stat -c %Y -- "$p")"
|
|
35
|
+
else
|
|
36
|
+
printf '%s\t-\n' "$p"
|
|
37
|
+
fi
|
|
38
|
+
done
|
|
39
|
+
SH
|
|
40
|
+
|
|
41
|
+
def stat_paths(host, paths)
|
|
42
|
+
return {} if paths.empty?
|
|
43
|
+
out, _err, status = Open3.capture3(
|
|
44
|
+
"ssh", *SSH_OPTS, host, STAT_SCRIPT,
|
|
45
|
+
stdin_data: paths.join("\n") + "\n"
|
|
46
|
+
)
|
|
47
|
+
return nil unless status.success?
|
|
48
|
+
|
|
49
|
+
result = {}
|
|
50
|
+
out.each_line do |line|
|
|
51
|
+
path, mtime = line.chomp.split("\t", 2)
|
|
52
|
+
next unless path && mtime
|
|
53
|
+
result[path] = mtime == "-" ? nil : Time.at(mtime.to_i)
|
|
54
|
+
end
|
|
55
|
+
result
|
|
56
|
+
rescue Errno::ENOENT
|
|
57
|
+
nil # ssh not installed
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create a directory on the remote side (mkdir -p equivalent).
|
|
61
|
+
def mkdir_p(host, dir)
|
|
62
|
+
_out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "mkdir", "-p", shellesc(dir))
|
|
63
|
+
status.success?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Escape one argument for the remote shell (ssh joins args with spaces and
|
|
67
|
+
# hands the string to a shell — local exec-style arrays don't protect it).
|
|
68
|
+
def shellesc(s)
|
|
69
|
+
"'" + s.gsub("'", "'\\\\''") + "'"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/twin/scanner.rb
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "open3"
|
|
3
3
|
|
|
4
|
+
require_relative "remote"
|
|
5
|
+
|
|
4
6
|
module Twin
|
|
5
7
|
# One YAML block from a sync-file, enriched with live filesystem state.
|
|
6
8
|
Job = Struct.new(
|
|
7
9
|
:program, :path, :description, :active, :excludes, :label,
|
|
8
10
|
:source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
|
|
9
11
|
:source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
|
|
12
|
+
:target_unreachable,
|
|
10
13
|
keyword_init: true,
|
|
11
14
|
) do
|
|
12
15
|
def source_path = File.join(source, path)
|
|
13
16
|
def target_path = File.join(target, target_path_field || path)
|
|
17
|
+
def remote? = Twin::Remote.remote?(target)
|
|
14
18
|
|
|
15
19
|
def status
|
|
16
20
|
return :disabled if active != 1
|
|
21
|
+
return :unreachable if target_unreachable
|
|
17
22
|
return :both_missing if !source_exists && !target_exists
|
|
18
23
|
return :missing_source unless source_exists
|
|
19
24
|
return :missing_target unless target_exists
|
|
@@ -41,7 +46,7 @@ module Twin
|
|
|
41
46
|
# Aggregate status across jobs — worst first.
|
|
42
47
|
def status
|
|
43
48
|
states = jobs.map(&:status)
|
|
44
|
-
%i[both_missing missing_source missing_target target_newer source_newer disabled in_sync]
|
|
49
|
+
%i[unreachable both_missing missing_source missing_target target_newer source_newer disabled in_sync]
|
|
45
50
|
.find { |s| states.include?(s) } || :in_sync
|
|
46
51
|
end
|
|
47
52
|
|
|
@@ -69,10 +74,35 @@ module Twin
|
|
|
69
74
|
raise "grubber returned invalid JSON: #{e.message}"
|
|
70
75
|
end
|
|
71
76
|
vars = cfg.var_map
|
|
72
|
-
records.filter_map do |r|
|
|
77
|
+
jobs = records.filter_map do |r|
|
|
73
78
|
context = "#{r["Program"]} in #{File.basename(r["_note_file"].to_s)}"
|
|
74
79
|
build_job(Twin::Template.substitute_record(r, vars, context: context), vars)
|
|
75
80
|
end
|
|
81
|
+
fill_remote_stats(jobs)
|
|
82
|
+
jobs
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remote targets can't be stat'ed locally — batch them into one ssh
|
|
86
|
+
# round-trip per host. A failed ssh marks the jobs unreachable instead
|
|
87
|
+
# of aborting the scan (local jobs stay usable).
|
|
88
|
+
def fill_remote_stats(jobs)
|
|
89
|
+
jobs.select { |j| j.remote? && j.active == 1 }
|
|
90
|
+
.group_by { |j| Twin::Remote.split(j.target).first }
|
|
91
|
+
.each do |host, host_jobs|
|
|
92
|
+
stats = Twin::Remote.stat_paths(host, host_jobs.map { |j| Twin::Remote.split(j.target_path).last })
|
|
93
|
+
host_jobs.each do |j|
|
|
94
|
+
rpath = Twin::Remote.split(j.target_path).last
|
|
95
|
+
if stats.nil?
|
|
96
|
+
j.target_unreachable = true
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
mtime = stats[rpath]
|
|
100
|
+
j.target_exists = !mtime.nil?
|
|
101
|
+
j.target_mtime = mtime
|
|
102
|
+
j.conflict = j.source_exists && mtime && j.source_mtime &&
|
|
103
|
+
mtime - j.source_mtime >= 60
|
|
104
|
+
end
|
|
105
|
+
end
|
|
76
106
|
end
|
|
77
107
|
|
|
78
108
|
def load_programs(cfg, file: nil, label: nil, show_all: false)
|
|
@@ -114,11 +144,18 @@ module Twin
|
|
|
114
144
|
|
|
115
145
|
render = r["Render"] == true
|
|
116
146
|
excludes = (r["Exclude"] || "").split(",").map(&:strip).reject(&:empty?)
|
|
147
|
+
remote = Twin::Remote.remote?(target)
|
|
148
|
+
|
|
149
|
+
if render && remote
|
|
150
|
+
raise "#{r["Program"]}: Render is not supported for remote targets (#{target})"
|
|
151
|
+
end
|
|
117
152
|
|
|
118
153
|
src_full = File.join(source, path)
|
|
119
154
|
tgt_full = File.join(target, target_path_field || path)
|
|
120
155
|
src_exists, src_mtime = stat(src_full)
|
|
121
|
-
|
|
156
|
+
# Remote targets are stat'ed in one batched ssh call after all jobs are
|
|
157
|
+
# built (fill_remote_stats) — until then they read as missing.
|
|
158
|
+
tgt_exists, tgt_mtime = remote ? [false, nil] : stat(tgt_full)
|
|
122
159
|
|
|
123
160
|
# Render jobs: status is content-based (mtime is meaningless for a rendered
|
|
124
161
|
# target). conflict stays false so the mtime conflict-warning skips them.
|
|
@@ -148,6 +185,7 @@ module Twin
|
|
|
148
185
|
source_mtime: src_mtime,
|
|
149
186
|
target_mtime: tgt_mtime,
|
|
150
187
|
conflict: !!conflict,
|
|
188
|
+
target_unreachable: false,
|
|
151
189
|
)
|
|
152
190
|
end
|
|
153
191
|
|
data/lib/twin/sync.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
|
+
require_relative "remote"
|
|
4
|
+
|
|
3
5
|
module Twin
|
|
4
6
|
module Sync
|
|
5
7
|
module_function
|
|
@@ -35,6 +37,8 @@ module Twin
|
|
|
35
37
|
# Render a template Job: read source, substitute {{vars}}, write if changed.
|
|
36
38
|
# Returns [success, output, changed].
|
|
37
39
|
def render_job(cfg, job, dry_run: false)
|
|
40
|
+
return [false, "render: remote targets are not supported", false] if job.remote?
|
|
41
|
+
|
|
38
42
|
src = job.source_path
|
|
39
43
|
tgt = job.target_path
|
|
40
44
|
|
|
@@ -89,21 +93,16 @@ module Twin
|
|
|
89
93
|
|
|
90
94
|
return [false, "source not found: #{src}", false] unless File.exist?(src)
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
98
|
-
job.excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
99
|
-
|
|
100
|
-
if File.directory?(src)
|
|
101
|
-
args << "#{src}/" << "#{tgt}/"
|
|
96
|
+
if job.remote?
|
|
97
|
+
host, rpath = Twin::Remote.split(tgt)
|
|
98
|
+
unless dry_run || Twin::Remote.mkdir_p(host, File.dirname(rpath))
|
|
99
|
+
return [false, "ssh: could not create #{File.dirname(rpath)} on #{host}", false]
|
|
100
|
+
end
|
|
102
101
|
else
|
|
103
|
-
|
|
102
|
+
FileUtils.mkdir_p(File.dirname(tgt))
|
|
104
103
|
end
|
|
105
104
|
|
|
106
|
-
output, status = run(
|
|
105
|
+
output, status = run(rsync_args(cfg, job, dry_run: dry_run))
|
|
107
106
|
return [false, output, false] unless status.success?
|
|
108
107
|
|
|
109
108
|
xfr = !dry_run && transferred?(output)
|
|
@@ -128,6 +127,44 @@ module Twin
|
|
|
128
127
|
[true, output, xfr]
|
|
129
128
|
end
|
|
130
129
|
|
|
130
|
+
# Full rsync argument vector for a job.
|
|
131
|
+
def rsync_args(cfg, job, dry_run: false)
|
|
132
|
+
src = job.source_path
|
|
133
|
+
tgt = job.target_path
|
|
134
|
+
|
|
135
|
+
args = ["rsync", "-av", "--itemize-changes", "--update"]
|
|
136
|
+
if job.delete
|
|
137
|
+
args << "--delete"
|
|
138
|
+
args.concat(backup_args(job))
|
|
139
|
+
end
|
|
140
|
+
args << "--dry-run" if dry_run
|
|
141
|
+
cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
142
|
+
job.excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
143
|
+
|
|
144
|
+
if File.directory?(src)
|
|
145
|
+
args << "#{src}/" << "#{tgt}/"
|
|
146
|
+
else
|
|
147
|
+
args << src << tgt
|
|
148
|
+
end
|
|
149
|
+
args
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Safety net for --delete: deleted and overwritten files land in a
|
|
153
|
+
# per-run backup dir on the target side (<target>/.twin-backup/<stamp>).
|
|
154
|
+
# rsync only creates the dir when it actually backs something up.
|
|
155
|
+
# The exclude keeps a backup dir inside the transfer root (Path: ".")
|
|
156
|
+
# from being deleted by the very sync it protects against.
|
|
157
|
+
def backup_args(job)
|
|
158
|
+
root = job.remote? ? Twin::Remote.split(job.target).last : job.target
|
|
159
|
+
dir = File.join(root, ".twin-backup", run_stamp)
|
|
160
|
+
["--backup", "--backup-dir=#{dir}", "--exclude=.twin-backup/"]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# One timestamp per twin process, so a multi-job run shares a backup dir.
|
|
164
|
+
def run_stamp
|
|
165
|
+
@run_stamp ||= Time.now.strftime("%Y-%m-%d_%H%M%S")
|
|
166
|
+
end
|
|
167
|
+
|
|
131
168
|
# Sync all jobs in a Program. Returns array of [job, success, output].
|
|
132
169
|
def run_program(cfg, program, dry_run: false)
|
|
133
170
|
program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
|
data/lib/twin/version.rb
CHANGED
data/lib/twin.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
require_relative "twin/version"
|
|
2
|
+
require_relative "twin/remote"
|
|
2
3
|
require_relative "twin/template"
|
|
3
4
|
require_relative "twin/config"
|
|
4
5
|
require_relative "twin/scanner"
|
|
5
6
|
require_relative "twin/sync"
|
|
7
|
+
require_relative "twin/journal"
|
|
8
|
+
require_relative "twin/add"
|
|
6
9
|
require_relative "twin/preview"
|
|
7
10
|
require_relative "twin/picker"
|
|
8
11
|
require_relative "twin/cli"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mark-twin
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ralf Hülsmann
|
|
@@ -37,10 +37,13 @@ files:
|
|
|
37
37
|
- README.md
|
|
38
38
|
- bin/twin
|
|
39
39
|
- lib/twin.rb
|
|
40
|
+
- lib/twin/add.rb
|
|
40
41
|
- lib/twin/cli.rb
|
|
41
42
|
- lib/twin/config.rb
|
|
43
|
+
- lib/twin/journal.rb
|
|
42
44
|
- lib/twin/picker.rb
|
|
43
45
|
- lib/twin/preview.rb
|
|
46
|
+
- lib/twin/remote.rb
|
|
44
47
|
- lib/twin/scanner.rb
|
|
45
48
|
- lib/twin/sync.rb
|
|
46
49
|
- lib/twin/template.rb
|