mark-twin 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a1efe3b9e90b401c9c6f8a25652db649ddea26054ede30f2c221d0d73246297
4
- data.tar.gz: b85e21f28805ade66696c9997b986b0472f2d3028971075a30f15d35ede113b7
3
+ metadata.gz: 5bae405ea7916f59395530f5372cd3bde2aa4ff742ca4cf08142ca16be5c7cd9
4
+ data.tar.gz: b05b48d23901e7c6c2fd313ecc0f8ef2dbf172ab77ef38b5575088031dc186da
5
5
  SHA512:
6
- metadata.gz: cf13dd6e51d53af11adc5b49c5e183ab6dd07ec9a49e4d83cafa60c712ca2bb492bb944f00b1afca864e051c09f7c054465d725d1da858d0960c48f6507778ff
7
- data.tar.gz: d7a00b682a1e8b833c7aceda323da4c69625fa3538def0810334dd1ed757696aca7d92d34d1e914bb06b60d1539ed6b990f5b24fdb94a59847bee2404272e51e
6
+ metadata.gz: 3d10977e33311149b1d90486c553cf6ce773c9c2927a081f3e264dcb9a8d0c97be61bb62883b070cb739ab66aa710d2d359b95db33018f8463bd0ac9987c0a95
7
+ data.tar.gz: ec1eecdf09d77e1c0ef6480edeb889c9d737b87cbd9eda5a9e83e210ca5fc2eeee85d06f86ccb65e81040197a8a4402001eeb79bda60710f44c77cd7e2e16c26
data/ARCHITECTURE.md CHANGED
@@ -19,6 +19,11 @@ sync-files (.md)
19
19
  └──▶ Picker fzf + apex preview, returns selected Program
20
20
 
21
21
 
22
+
23
+
24
+ Conflict paired dry-runs → files --update would hold
25
+ │ back → content compare → ask once, or abort
26
+
22
27
  Sync rsync (or render) per Job, mount check, Cmd hook
23
28
  ```
24
29
 
@@ -32,6 +37,7 @@ lib/twin/
32
37
  config.rb ~/.config/twin/config.yaml loader; host table → var_map
33
38
  scanner.rb Job, Program structs; grubber + template + stat → grouped Programs
34
39
  sync.rb rsync / render execution, mount check, post-sync hook
40
+ conflict.rb target-side changes: detection via paired dry-runs, diffs
35
41
  journal.rb append-only sync journal (~/.local/state/twin/log.jsonl)
36
42
  add.rb `twin add` — interactive scaffolding of new sync entries
37
43
  picker.rb fzf wrapper with apex preview
@@ -46,11 +52,16 @@ test/test_pure.rb
46
52
  **Job** — one YAML block:
47
53
 
48
54
  ```
49
- program, path, description, active, excludes, label, source, target, cmd,
55
+ program, path, description, active, excludes, owned, label, source, target, cmd,
50
56
  delete, render, render_outdated, target_path_field, sync_file,
51
57
  source_exists, target_exists, source_mtime, target_mtime, conflict
52
58
  ```
53
59
 
60
+ `excludes` and `owned` both become `--exclude` (via `Job#all_excludes`); they
61
+ are kept apart so `status` can report intent. `conflict` is the coarse mtime
62
+ flag used by `status` only — conflict *resolution* ignores it and asks rsync
63
+ directly, because a directory's mtime says nothing about edits inside it.
64
+
54
65
  `Job#status` → one of `disabled / unreachable / both_missing / missing_source /
55
66
  missing_target / target_newer / in_sync / source_newer`. Render jobs derive status from content
56
67
  (`render_outdated`), not mtime; non-render jobs ignore those fields.
@@ -65,6 +76,22 @@ name, jobs
65
76
  `Program#status` aggregates jobs (worst state wins). Selection in the picker
66
77
  operates on Programs, not individual Jobs.
67
78
 
79
+ **Job order is part of the contract.** Jobs of a Program run in the order their
80
+ YAML blocks appear in the sync-file. Sync-files rely on this — a `Cmd` that
81
+ restarts a service belongs in the last block, so it fires after every path is in
82
+ place. Reorder the jobs and a deploy restarts against half-written state,
83
+ without any error to show for it.
84
+
85
+ The order is guaranteed at both ends of the pipeline, not merely observed:
86
+
87
+ - grubber emits records in document order — first block, first record — for all
88
+ three output formats, pinned by its own `TestBlockOrderFollowsDocument`
89
+ across `Extract` and `StreamJSONL`.
90
+ - twin preserves it through `filter_map` and `group_by` (insertion order per
91
+ key), pinned by `test_job_order_follows_document_order`.
92
+
93
+ Neither side may quietly sort.
94
+
68
95
  ## Configuration
69
96
 
70
97
  `~/.config/twin/config.yaml`:
data/README.md CHANGED
@@ -4,26 +4,56 @@
4
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
5
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- Sync configuration folders between two Macs from self-documenting Markdown files.
7
+ Sync configuration between machines, from Markdown files that explain
8
+ themselves.
8
9
 
9
- Sync entries are defined in Markdown files with YAML blockshuman-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).
10
+ A sync-file is a normal Markdown document. The prose explains it to you and
11
+ to whatever assistant you point at the file later. The fenced YAML blocks are
12
+ what twin acts on:
14
13
 
15
- ## Why?
14
+ ````markdown
15
+ ---
16
+ Active: 1
17
+ Source: /Users/admin
18
+ Target: admin@macbook:/Users/admin
19
+ ---
20
+
21
+ ## Fish Shell
16
22
 
17
- Sync-definitions in Markdown + YAML are three things at once:
23
+ Shell config, including completions and abbreviations. `local.fish` stays
24
+ machine-specific — the laptop keeps its own.
25
+
26
+ ```yaml
27
+ Program: Fish Shell
28
+ Path: .config/fish
29
+ Description: Fish Shell configuration
30
+ Own: conf.d/local.fish
31
+ ```
32
+ ````
18
33
 
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.
34
+ `twin` then shows you what differs and syncs what you pick. Under the
35
+ hood it is `rsync`; the point is the file above, which still tells you in a
36
+ year *why* a path is synced, not just that it is.
37
+
38
+ That is the whole idea, and for most entries it stays exactly that small. The
39
+ rest of this README is long because twin does handle the awkward cases — files
40
+ the other machine owns, both sides changed since last time, paths that differ
41
+ per host — not because you need any of them on day one. Skip what you don't
42
+ need.
43
+
44
+ ## Why Markdown
45
+
46
+ - **You can read it later.** The reason a path is synced lives next to the
47
+ path, in prose, not in a comment you stopped writing after the third entry.
48
+ - **Machines can read it too.** The YAML blocks are extracted by
49
+ [grubber](https://github.com/rhsev/grubber), so the same file can feed other
50
+ tools, not just twin.
51
+ - **It stays editable by hand.** No generated state, no database. Add a block
52
+ in your editor and twin picks it up.
53
+ - **And yes, ask your AI to write them.** Markdown with YAML blocks is exactly
54
+ what language models are good at. Show one an existing sync-file, describe
55
+ the next entry, and what comes back is valid for grubber and still readable
56
+ by you — which is the whole bargain of this format.
27
57
 
28
58
  ## Screenshots
29
59
 
@@ -37,22 +67,21 @@ compact preview of the relevant sync-file section, rendered by apex:
37
67
 
38
68
  ![Stage 2 — Fish Shell paths with apex preview](https://raw.githubusercontent.com/rhsev/mark-twin/main/docs/stage_2_fish.png)
39
69
 
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
70
+ ## Install
50
71
 
51
72
  ```bash
52
73
  gem install mark-twin
74
+ brew install fzf
53
75
  ```
54
76
 
55
- Or from source:
77
+ Plus [grubber](https://github.com/rhsev/grubber/releases), a small Go binary —
78
+ download it and put it in your `PATH`. `rsync` ships with macOS.
79
+
80
+ Optional: one of `apex`, `glow` or `bat` for the preview pane (tried in that
81
+ order, `cat` if none are present). `twin doctor` reports what it found.
82
+
83
+ <details>
84
+ <summary>From source</summary>
56
85
 
57
86
  ```bash
58
87
  git clone https://github.com/rhsev/mark-twin.git
@@ -60,242 +89,235 @@ cd mark-twin
60
89
  gem build mark-twin.gemspec
61
90
  gem install ./mark-twin-*.gem
62
91
  ```
92
+ </details>
63
93
 
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).
94
+ ## Getting started
70
95
 
71
- ## Quickstart
96
+ **1. Decide where the other side lives.** `Target:` takes either form, and
97
+ neither is the special case:
72
98
 
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.
99
+ ```yaml
100
+ Target: admin@macbook:/Users/admin # over ssh
101
+ Target: /Volumes/macbook/Users/admin # a mounted volume (SMB, NFS, …)
102
+ ```
75
103
 
76
- 1. **Pick a directory for sync-files** (anywhere; this example uses `~/Sync`):
104
+ Over ssh you need key-based login twin runs `ssh -o BatchMode=yes` and never
105
+ prompts for a password, so set up `ssh-copy-id macbook` first. A mounted volume
106
+ needs no keys but has to be mounted; twin checks before every sync. The
107
+ [comparison below](#mounted-volume-or-ssh) covers the two differences that
108
+ actually matter.
77
109
 
78
- ```bash
79
- mkdir -p ~/Sync
80
- ```
110
+ **2. Point twin at a folder for sync-files**, in `~/.config/twin/config.yaml`:
81
111
 
82
- 2. **Create the config** at `~/.config/twin/config.yaml`:
112
+ ```yaml
113
+ sync_dir: ~/Sync
83
114
 
84
- ```yaml
85
- sync_dir: ~/Sync
86
- global_excludes:
87
- - .DS_Store
88
- - .git/
89
- ```
115
+ global_excludes:
116
+ - .DS_Store
117
+ - .git/
118
+ ```
90
119
 
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:
120
+ **3. Write a sync-file**, or start from one of the [examples](examples/):
93
121
 
94
- ```bash
95
- cp examples/home.md ~/Sync/
96
- $EDITOR ~/Sync/home.md # edit Source: and Target:
97
- ```
122
+ ```bash
123
+ mkdir -p ~/Sync
124
+ cp examples/home.md ~/Sync/
125
+ $EDITOR ~/Sync/home.md # adjust Source: and Target:
126
+ ```
98
127
 
99
- 4. **Run twin**:
128
+ `twin add ~/.config/fish` does the same interactively, if you prefer prompts to
129
+ an editor — it finds the matching sync-file, derives the relative path,
130
+ suggests excludes for what it sees in the directory, and appends a block with a
131
+ prose stub.
100
132
 
101
- ```bash
102
- twin
103
- ```
133
+ **4. Look before you leap:**
104
134
 
105
- Pick a program, then the paths to sync, hit Enter.
135
+ ```bash
136
+ twin status # what differs
137
+ twin sync --dry-run # what a sync would do, without doing it
138
+ twin # interactive: pick a program, pick paths, Enter
139
+ ```
106
140
 
107
- ## Usage
141
+ That is the whole loop. Everything below is detail you can come back for.
108
142
 
109
- Twin has two modes: an **interactive interface** (default — see screenshots
110
- above) and **CLI commands** for status checks and batch sync.
143
+ ## Everyday commands
111
144
 
112
145
  ```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 # TUIany sync-file by absolute path
116
- twin ./relative/dir/ # TUI — all sync-files in a directory
146
+ twin # picker — all programs across all sync-files
147
+ twin home.md # picker — one sync-file in sync_dir (by name)
148
+ twin ./some/dir/ # pickerall sync-files in a directory
117
149
  twin list # plain listing
118
150
  twin status # listing with source/target mtimes
119
151
  twin sync -p grubber # sync one program by name pattern
120
152
  twin sync --file=repos # sync all programs from a sync-file
121
153
  twin sync --dry-run # preview without writing
122
154
  twin log # recent journal entries (-n N, --json)
123
- twin doctor # check tools, renderers, and sync targets
155
+ twin doctor # check tools, renderers, and targets
124
156
  twin --help # show usage
125
157
  ```
126
158
 
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
-
174
- File argument resolution:
159
+ A file argument without `/` is matched by substring against sync-file names in
160
+ `sync_dir`; anything containing `/` is treated as a path, file or directory.
175
161
 
176
- - bare name (no `/`) → looked up by substring in `sync_dir`
177
- - contains `/` → resolved as path (absolute or relative); file or directory both work
178
-
179
- ## Configuration
162
+ Every job is journaled to `~/.local/state/twin/log.jsonl` one JSON line with
163
+ timestamp, program, path and outcome. `twin sync` exits non-zero if any job
164
+ failed.
180
165
 
181
- `~/.config/twin/config.yaml`:
182
-
183
- ```yaml
184
- sync_dir: /path/to/sync-files
166
+ ## Sync-files
185
167
 
186
- global_excludes:
187
- - .DS_Store
188
- - .git/
168
+ One Markdown file per relationship. Frontmatter sets it up, YAML blocks define
169
+ the individual paths. Frontmatter fields are merged into every block, so
170
+ `Source:`/`Target:` are usually written once at the top — but a block may
171
+ override them, which is how one file can serve several destinations.
189
172
 
190
- # Optional preview rendering (apex):
191
- # apex_theme: default
192
- # apex_width: 80
193
- # apex_code_highlight: monokai
194
- # apex_code_highlight_theme: dark
195
- ```
173
+ Blocks sharing a `Program` are grouped: they are selected together, synced
174
+ together, and **run in the order they appear in the file**.
196
175
 
197
- Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`, `TWIN_HOST` (which host
198
- twin runs as — lets one config serve both machines).
176
+ ### Field reference
199
177
 
200
- ## Sync-files
178
+ Field names are capitalised English. An unknown key is ignored silently and a
179
+ missing `Active` counts as `0`, so a typo shows up as an entry that never syncs
180
+ rather than as an error.
201
181
 
202
- Each Markdown file represents one sync relationship. Frontmatter defines the
203
- relationship (Source/Target); YAML blocks define individual paths.
182
+ | Field | Where | Meaning |
183
+ |---|---|---|
184
+ | `Program` | block | Group name; blocks sharing it sync together |
185
+ | `Path` | block | Path relative to `Source` (file or directory) |
186
+ | `Source` | either | Absolute base path on this machine |
187
+ | `Target` | either | Absolute base path, or `user@host:/path` for ssh |
188
+ | `Target-Path` | block | Path under `Target`, when it differs from `Path` |
189
+ | `Active` | either | `1` syncs, `0` skips. Default `0` |
190
+ | `Description` | block | Shown in listings and the picker |
191
+ | `Label` | either | Free-text grouping, filterable via `--label` |
192
+ | `Exclude` | block | Comma-separated paths that are not part of the sync |
193
+ | `Own` | block | Comma-separated paths the **target** owns |
194
+ | `Delete` | block | `true` mirrors deletions, with backups |
195
+ | `Cmd` | block | Shell command, run only when bytes actually moved |
196
+ | `Render` | block | `true` substitutes `{{tokens}}` instead of copying |
197
+
198
+ ### Exclude or Own?
199
+
200
+ Both keep rsync away from a path and both take a comma-separated list
201
+ (`Exclude: *.log, __pycache__/`). What differs is the meaning, and `twin status`
202
+ reports them apart:
203
+
204
+ - **`Exclude:`** — not part of this sync at all. Build artefacts, logs, caches,
205
+ `.git/`, a test script with no business on the other machine.
206
+ - **`Own:`** — inside the sync scope, but the **target** owns it.
207
+ Machine-specific configuration the source must never clobber:
208
+ `conf.d/local.fish`, `lazy-lock.json`, a per-host credentials file.
209
+
210
+ The distinction is documentation, not mechanism. Six months on, `Own:` still
211
+ says "deliberate, the other machine maintains this", where the same entry sitting
212
+ in `Exclude:` between `*.dwarf` and `.DS_Store` reads like noise you once
213
+ filtered out.
214
+
215
+ ### Cmd: doing something after a sync
216
+
217
+ `Cmd` runs a shell command once rsync has actually transferred bytes — a no-op
218
+ sync runs nothing. Typically a `curl` to a local automation endpoint like
219
+ [mi.lan](https://github.com/rhsev/mi.lan), or an `ssh host '…'` for remote
220
+ targets (`Cmd` always runs locally).
221
+
222
+ It belongs to a block rather than to the program, which is what you want when
223
+ different paths need different follow-ups — reload nginx after its config,
224
+ re-link binaries after `bin/`. For a **restart**, put the command in the
225
+ **last** block: jobs run in file order, so a restart placed earlier brings the
226
+ service back before the remaining paths are written. The same command repeated
227
+ across blocks restarts repeatedly, for the same reason. One command, last block.
228
+
229
+ ### Delete: mirroring removals
230
+
231
+ `Delete: true` adds `--delete`, so files removed from the source disappear on
232
+ the target too. Deleted and overwritten files are moved to
233
+ `<target>/.twin-backup/<timestamp>/` rather than destroyed — a safety net worth
234
+ pruning occasionally. It applies to `Delete` jobs only.
235
+
236
+ ## Mounted volume or ssh
237
+
238
+ Both are first-class. `twin status`, the picker, `Exclude`/`Own`, `Delete` and
239
+ `Cmd` behave identically; remote paths are stat'ed in a single ssh round-trip
240
+ per host, and an unreachable host shows as `?` instead of failing the scan.
241
+
242
+ Two differences are real:
243
+
244
+ | | mounted volume | ssh |
245
+ |---|---|---|
246
+ | Setup | volume must be mounted | ssh key (`ssh-copy-id`) |
247
+ | Before syncing | mount check | reachability check |
248
+ | `Render: true` | supported | **not** supported |
204
249
 
205
- See [examples/home.md](examples/home.md) and [examples/repos.md](examples/repos.md)
206
- for ready-to-adapt templates.
250
+ `Render` needs to read and write file contents on the target, which twin only
251
+ does locally. Keep rendered files on mounted targets, or render locally and sync
252
+ the result.
207
253
 
208
- Minimal example:
254
+ Syncing is push-only in both cases: `Source:` is always this machine.
209
255
 
210
- ````markdown
256
+ ```markdown
211
257
  ---
212
258
  Active: 1
213
- Label: mac-mini → macbook
214
- Source: /Users/admin
215
- Target: /Volumes/macbook/Users/admin
259
+ Label: mini → server
260
+ Source: /Volumes/lightning/Git/Website
261
+ Target: ralf@server:/srv/www
216
262
  ---
263
+ ```
217
264
 
218
- ## Fish Shell
265
+ ## When the target has changed too
219
266
 
220
- Configuration for the fish shell, including completions and abbreviations.
267
+ A sync has a direction: the source wins. But targets get edited — a quick fix
268
+ made on the server at midnight, a config tweaked where it runs. Twin looks for
269
+ that before it moves the first byte, and asks once for the whole program:
221
270
 
222
- ```yaml
223
- Program: Fish Shell
224
- Path: .config/fish
225
- Description: Fish Shell configuration
226
- Exclude: conf.d/local.fish
227
271
  ```
228
- ````
272
+ target has changed since the last sync — 1 file(s) differ:
273
+ ! app/code.rb (target 2h newer)
274
+ syncing would replace them with the source version.
229
275
 
230
- Frontmatter fields (`Active`, `Label`, `Source`, `Target`) are merged into
231
- every block by grubber. Multiple blocks can share the same `Program` — twin
232
- groups them and treats the program as the unit of selection.
233
-
234
- The optional `Cmd` field runs an arbitrary shell command after a successful
235
- sync — typically a `curl` to a local automation endpoint like
236
- [mi.lan](https://github.com/rhsev/mi.lan) to reload a program, restart a
237
- service, or notify another machine. The command only runs when rsync actually
238
- transferred bytes; no-op syncs skip it. See the Helix entry in
239
- [examples/home.md](examples/home.md).
276
+ overwrite these on the target and sync? [y]es / [d]iff / [n]o (abort)
277
+ ```
240
278
 
241
- The optional `Delete: true` field adds `--delete` to the rsync invocation,
242
- so files removed from the source are also removed on the target. Useful for
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.
279
+ `d` prints a unified diff per file, then asks again. `n` aborts the entire
280
+ program nothing is written, so you never end up with half a deploy applied.
247
281
 
248
- ## SSH targets
282
+ Two properties make this bearable day to day:
249
283
 
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.
284
+ - **Content, not timestamps.** A file that is merely newer on the target with
285
+ identical bytes is not a conflict and does not ask. Sync a tree in both
286
+ directions and you collect dozens of those; a prompt that fires on them gets
287
+ answered without being read.
288
+ - **Directory mtimes are ignored.** Editing a file in place leaves its
289
+ directory's mtime untouched, and `rsync -a` equalises those anyway. Twin asks
290
+ rsync what it would actually transfer instead of guessing from a directory.
253
291
 
254
- ````markdown
255
- ---
256
- Active: 1
257
- Label: mini → server
258
- Source: /Volumes/lightning/Git/Website
259
- Target: ralf@server:/srv/www
260
- ---
292
+ `twin status` is still mtime-based and cannot see an in-place edit. It is the
293
+ cheap overview; `twin sync` is what decides.
261
294
 
262
- ## Website
295
+ ## Automation
263
296
 
264
- Static site, deployed straight from the build directory.
297
+ For a scheduled run (launchd, cron):
265
298
 
266
- ```yaml
267
- Program: website
268
- Path: public
269
- Description: static site
270
- Exclude: .git/
271
- Cmd: ssh ralf@server 'sudo systemctl reload caddy'
299
+ ```bash
300
+ twin sync --quiet --skip-unavailable --skip-conflicts
272
301
  ```
273
- ````
274
302
 
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.
303
+ - `--quiet` — only conflicts, errors and jobs that changed something. A no-op
304
+ run is silent.
305
+ - `--skip-unavailable` a laptop that isn't docked is skipped, not an error.
306
+ - `--skip-conflicts` leave target-side changes alone and sync the rest.
307
+ Use `--force` instead to overwrite them.
308
+
309
+ Without a terminal and without one of those two flags, a real conflict aborts
310
+ the run with exit code 1 rather than picking an answer for you. Output and a
311
+ non-zero exit therefore mean something genuinely needs attention which is
312
+ what launchd's logging wants. The journal records every job regardless.
292
313
 
293
314
  ## Templating
294
315
 
295
- Some configs differ per machine a LaunchAgent plist that points at
296
- `/Volumes/lightning/…` on one Mac and `/Users/ralf/…` on another, a
297
- `settings.json` with a device-specific id. Those used to fall out of twin and
298
- get hand-maintained. Templating folds them back into one source of truth.
316
+ *Skip this until you hit the problem it solves.*
317
+
318
+ Some configs differ per machine a LaunchAgent plist pointing at
319
+ `/Volumes/lightning/…` on one Mac and `/Users/ralf/…` on another. Those used to
320
+ fall out of twin and get hand-maintained.
299
321
 
300
322
  Define a host table in `~/.config/twin/config.yaml`:
301
323
 
@@ -326,9 +348,8 @@ The distinction matters: a file *written* to the mount (`/Volumes/ralf/…`) but
326
348
 
327
349
  `Render: true` turns a block from copy into *render*: twin reads the source as a
328
350
  template, substitutes `{{…}}` in its **content**, and writes the result only if
329
- it differs from the current target (so a `Cmd` hook fires only on a real change).
330
- `Target-Path:` overrides the target-side relative path when it differs from the
331
- source layout:
351
+ it differs from the current target, so a `Cmd` hook fires only on a real change.
352
+ `Target-Path:` overrides the target-side relative path:
332
353
 
333
354
  ````markdown
334
355
  ## LiveSync LaunchAgent
@@ -344,15 +365,35 @@ Cmd: curl -s http://mi.lan/livesync-reload
344
365
  ```
345
366
  ````
346
367
 
347
- `twin doctor` checks that every `{{token}}` across your sync-files resolves, and
348
- `twin status` compares rendered output by content (not mtime). Without a `hosts`
349
- table, templating is inert and literal-path sync-files behave exactly as before.
368
+ `twin doctor` checks that every `{{token}}` resolves, and `twin status` compares
369
+ rendered output by content rather than mtime. Without a `hosts` table,
370
+ templating is inert and literal-path sync-files behave exactly as before.
371
+
372
+ ## Configuration
373
+
374
+ `~/.config/twin/config.yaml`:
375
+
376
+ ```yaml
377
+ sync_dir: /path/to/sync-files
378
+
379
+ global_excludes:
380
+ - .DS_Store
381
+ - .git/
382
+
383
+ # Optional preview rendering (apex):
384
+ # apex_theme: default
385
+ # apex_width: 80
386
+ # apex_code_highlight: monokai
387
+ # apex_code_highlight_theme: dark
388
+ ```
389
+
390
+ Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`, `TWIN_HOST` (which host
391
+ twin runs as — lets one config serve both machines).
350
392
 
351
393
  ## Design
352
394
 
353
- Sync instructions and context in one place the same Markdown file holds
354
- both the `Path:` directives and the prose explaining them. No TUI framework:
355
- `fzf` does the interactive part, `apex` the rendering.
395
+ Sync instructions and their context in one place. No TUI framework: `fzf` does
396
+ the interactive part, `apex` the rendering, `rsync` the work.
356
397
 
357
398
  See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
358
399
 
data/lib/twin/cli.rb CHANGED
@@ -25,6 +25,7 @@ module Twin
25
25
  twin status [--all] [--label X] [--file X] [--json]
26
26
  twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
27
27
  [--quiet] [--skip-unavailable]
28
+ [--force] [--skip-conflicts]
28
29
  twin add <path> scaffold a new sync entry for a local path
29
30
  twin log [-n N] [--json] recent journal entries (default 20)
30
31
  twin doctor check tools, renderers, and sync targets
@@ -34,6 +35,12 @@ module Twin
34
35
  bare name (no /) → matched by substring against sync-file names
35
36
  contains / → resolved as path; file or directory both work
36
37
 
38
+ TARGET-SIDE CHANGES:
39
+ Before syncing, twin looks for files the target changed more recently
40
+ AND whose content differs, then asks once for the whole program.
41
+ --force overwrite them without asking (for automation)
42
+ --skip-conflicts leave them alone, sync everything else
43
+
37
44
  CONFIG:
38
45
  ~/.config/twin/config.yaml
39
46
  TWIN_SYNC_DIR overrides sync_dir
@@ -149,6 +156,8 @@ module Twin
149
156
  puts " #{j.path}#{conflict}"
150
157
  puts " src #{src}"
151
158
  puts " dst #{tgt}"
159
+ # Named, not hidden: these belong to the target on purpose.
160
+ puts " own #{j.owned.join(', ')}" unless j.owned.nil? || j.owned.empty?
152
161
  end
153
162
  end
154
163
  end
@@ -171,7 +180,8 @@ module Twin
171
180
  results = programs.map do |p|
172
181
  sync_jobs(cfg, p, p.active_jobs,
173
182
  dry_run: opts[:dry_run], quiet: opts[:quiet],
174
- skip_unavailable: opts[:skip_unavailable])
183
+ skip_unavailable: opts[:skip_unavailable],
184
+ force: opts[:force], skip_conflicts: opts[:skip_conflicts])
175
185
  end
176
186
  exit 1 unless results.all?
177
187
  end
@@ -179,7 +189,8 @@ module Twin
179
189
  # Sync the given jobs. Returns true when every attempted job succeeded.
180
190
  # quiet: print only conflicts, errors, and jobs that changed something
181
191
  # 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)
192
+ def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false,
193
+ force: false, skip_conflicts: false)
183
194
  jobs = jobs.select { |j| j.active == 1 }
184
195
  return true if jobs.empty?
185
196
 
@@ -199,17 +210,17 @@ module Twin
199
210
  end
200
211
  return true if jobs.empty?
201
212
 
202
- conflicts = jobs.select(&:conflict)
203
- unless conflicts.empty?
204
- warn "warning: target is newer than source:"
205
- conflicts.each { |j| warn " ! #{j.path}" }
206
- warn "continuing sync (--update skips newer files on target)."
207
- end
213
+ # Decide about target-side changes BEFORE the first byte moves: a partly
214
+ # applied program is worse than none at all. resolve_conflicts returns
215
+ # false when the run must not happen.
216
+ force = resolve_conflicts(cfg, jobs, dry_run: dry_run, quiet: quiet,
217
+ force: force, skip_conflicts: skip_conflicts)
218
+ return false if force == :abort
208
219
 
209
220
  header_printed = false
210
221
  all_ok = true
211
222
  jobs.each do |job|
212
- success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
223
+ success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run, force: force)
213
224
  Twin::Journal.record(job, success: success, transferred: transferred, output: output) unless dry_run
214
225
  all_ok &&= success
215
226
  next if quiet && success && !transferred
@@ -225,6 +236,67 @@ module Twin
225
236
  all_ok
226
237
  end
227
238
 
239
+ # Settle what happens to files the target changed more recently, before any
240
+ # job runs. Returns true (overwrite them), false (leave them, --update keeps
241
+ # them) or :abort (sync nothing at all).
242
+ #
243
+ # The mtime pre-filter on each Job is coarse and fires often; only files
244
+ # whose content really differs reach the prompt. A prompt that cries wolf
245
+ # gets answered without reading it.
246
+ def resolve_conflicts(cfg, jobs, dry_run:, quiet:, force:, skip_conflicts:)
247
+ return true if force
248
+ return false if skip_conflicts || dry_run
249
+
250
+ conflicts = jobs.flat_map { |j| Twin::Conflict.detect(cfg, j) }
251
+ return false if conflicts.empty?
252
+
253
+ report_conflicts(conflicts)
254
+
255
+ unless $stdin.tty? && $stdout.tty?
256
+ warn ""
257
+ warn "abort: target has changes of its own and there is no terminal to ask."
258
+ warn " re-run with --force to overwrite them, or --skip-conflicts to keep them."
259
+ return :abort
260
+ end
261
+
262
+ loop do
263
+ print "\noverwrite these on the target and sync? [y]es / [d]iff / [n]o (abort) "
264
+ $stdout.flush
265
+ case $stdin.gets&.strip&.downcase
266
+ when "y", "yes" then return true
267
+ when "n", "no", "", nil then puts "aborted — nothing was synced."; return :abort
268
+ when "d", "diff" then show_diffs(conflicts)
269
+ else puts "please answer y, d or n."
270
+ end
271
+ end
272
+ end
273
+
274
+ def report_conflicts(conflicts)
275
+ warn "target has changed since the last sync — #{conflicts.size} file(s) differ:"
276
+ conflicts.each do |c|
277
+ delta = c.age_delta
278
+ age = delta ? " (target #{format_age(delta)} newer)" : ""
279
+ warn " ! #{c.job.path == c.rel ? c.rel : File.join(c.job.path, c.rel)}#{age}"
280
+ end
281
+ warn "syncing would replace them with the source version."
282
+ end
283
+
284
+ def show_diffs(conflicts)
285
+ conflicts.each do |c|
286
+ puts
287
+ puts "── #{c.rel} " + "─" * [0, 60 - c.rel.length].max
288
+ puts Twin::Conflict.diff(c)
289
+ end
290
+ end
291
+
292
+ def format_age(seconds)
293
+ s = seconds.to_i.abs
294
+ return "#{s}s" if s < 90
295
+ return "#{s / 60}m" if s < 5400
296
+ return "#{s / 3600}h" if s < 172_800
297
+ "#{s / 86_400}d"
298
+ end
299
+
228
300
  # :ok, or a human-readable reason the target can't be synced right now.
229
301
  def target_availability(job)
230
302
  if job.remote?
@@ -374,11 +446,13 @@ module Twin
374
446
 
375
447
  def parse_sync_opts(args)
376
448
  opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
377
- quiet: false, skip_unavailable: false }
449
+ quiet: false, skip_unavailable: false, force: false, skip_conflicts: false }
378
450
  OptionParser.new do |o|
379
451
  o.on("--all") { opts[:show_all] = true }
380
452
  o.on("--label=L") { |v| opts[:label] = v }
381
453
  o.on("--file=F") { |v| opts[:file] = v }
454
+ o.on("--force") { opts[:force] = true }
455
+ o.on("--skip-conflicts") { opts[:skip_conflicts] = true }
382
456
  o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
383
457
  o.on("--dry-run") { opts[:dry_run] = true }
384
458
  o.on("-q", "--quiet") { opts[:quiet] = true }
@@ -0,0 +1,143 @@
1
+ require "digest"
2
+
3
+ require_relative "remote"
4
+
5
+ module Twin
6
+ # Finding out which files on the target would be silently skipped by rsync's
7
+ # --update, and whether that actually matters.
8
+ #
9
+ # `Job#conflict` is no help here. It compares the mtime of the job's own path,
10
+ # and for a directory job that is the directory's mtime — which says nothing
11
+ # about the files inside it. Worse, it is wrong in exactly the case that
12
+ # matters: editing a file in place leaves its directory's mtime untouched, and
13
+ # `rsync -a` equalises directory mtimes on every run anyway. A hand-edit on
14
+ # the target is therefore invisible to it.
15
+ #
16
+ # So we ask rsync, which has the answer already and knows its own matching
17
+ # rules better than any reimplementation would:
18
+ #
19
+ # 1. Which files does --update hold back?
20
+ # Dry-run twice, once with --update and once without. Everything the
21
+ # second run would transfer but the first would not is exactly the set
22
+ # --update protects.
23
+ #
24
+ # 2. Of those, which differ in content?
25
+ # Only these are worth asking about. A file that is merely newer — same
26
+ # bytes, later timestamp — is noise, and noise is what turns a prompt
27
+ # into a reflex. Sync both sides of a tree in either order and you get
28
+ # dozens of them.
29
+ #
30
+ # The first dry-run is also the cheap exit: when a forced run would move
31
+ # nothing, the job is fully in sync and the second run is skipped. That is the
32
+ # common case, so a quiet sync costs one extra stat-walk per job and no more.
33
+ module Conflict
34
+ # One file the target owns more recently than the source, with content that
35
+ # actually differs. `same_content` is nil when it could not be determined
36
+ # (remote targets) — treated as a conflict, because guessing in the other
37
+ # direction would overwrite work.
38
+ Entry = Struct.new(:job, :rel, :source_path, :target_path,
39
+ :source_mtime, :target_mtime, keyword_init: true) do
40
+ def age_delta
41
+ return nil unless source_mtime && target_mtime
42
+ target_mtime - source_mtime
43
+ end
44
+ end
45
+
46
+ # rsync --itemize-changes line → relative path. Change lines start with an
47
+ # update type and a file type (">f.st...... lib/foo.rb"); "*deleting" and
48
+ # the surrounding prose do not match.
49
+ ITEMIZE_LINE = /\A[<>ch][fdLDS]\S*\s+(.+?)\s*\z/
50
+
51
+ module_function
52
+
53
+ # Real conflicts for one job, in the order rsync reports them.
54
+ # Empty when --update holds nothing back, or holds back only identical files.
55
+ def detect(cfg, job)
56
+ # Render jobs compare by content already and never use --update.
57
+ return [] if job.render
58
+ return [] unless job.source_exists && job.target_exists
59
+
60
+ held_back_paths(cfg, job).filter_map { |rel| entry_for(job, rel) }
61
+ end
62
+
63
+ # Relative paths that --update would skip: (would transfer forced) minus
64
+ # (would transfer normally).
65
+ def held_back_paths(cfg, job)
66
+ forced = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: true))
67
+ return [] if forced.empty? # nothing to move at all — no need to ask rsync twice
68
+
69
+ normal = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: false))
70
+ forced - normal
71
+ end
72
+
73
+ def itemized_paths(args)
74
+ output, status = Twin::Sync.run(args)
75
+ return [] unless status.success?
76
+ output.lines.filter_map do |line|
77
+ m = ITEMIZE_LINE.match(line)
78
+ next unless m
79
+ rel = m[1]
80
+ next if rel == "./" || rel.end_with?("/") # directories carry no content
81
+ rel
82
+ end
83
+ end
84
+
85
+ # Build an Entry unless source and target hold the same bytes.
86
+ def entry_for(job, rel)
87
+ src = resolve(job.source_path, rel)
88
+ tgt = resolve(job.target_path, rel)
89
+
90
+ # Remote targets can't be read here; report them rather than assume.
91
+ unless job.remote?
92
+ return nil if same_content?(src, tgt)
93
+ end
94
+
95
+ Entry.new(
96
+ job: job, rel: rel, source_path: src, target_path: tgt,
97
+ source_mtime: mtime(src), target_mtime: job.remote? ? nil : mtime(tgt),
98
+ )
99
+ end
100
+
101
+ # A job path may be a single file — rsync then itemizes its basename, and
102
+ # the job path is already the full path.
103
+ def resolve(base, rel)
104
+ File.directory?(base) ? File.join(base, rel) : base
105
+ end
106
+
107
+ def same_content?(a, b)
108
+ return false unless File.file?(a) && File.file?(b)
109
+ return false unless File.size(a) == File.size(b)
110
+ digest(a) == digest(b)
111
+ rescue Errno::ENOENT, Errno::EACCES
112
+ false
113
+ end
114
+
115
+ def digest(path) = Digest::SHA256.file(path).hexdigest
116
+
117
+ def mtime(path)
118
+ File.mtime(path)
119
+ rescue Errno::ENOENT, Errno::EACCES
120
+ nil
121
+ end
122
+
123
+ # Unified diff for one entry, or a short note when it can't be produced.
124
+ def diff(entry)
125
+ return " (remote target — no diff available)" if entry.job.remote?
126
+ return " (binary or unreadable)" unless text?(entry.source_path) && text?(entry.target_path)
127
+
128
+ out, _status = Twin::Sync.run([
129
+ "diff", "-u",
130
+ "--label", "target (#{entry.target_path})", entry.target_path,
131
+ "--label", "source (#{entry.source_path})", entry.source_path,
132
+ ])
133
+ out.empty? ? " (no textual difference)" : out
134
+ end
135
+
136
+ # Cheap heuristic: a NUL byte in the first 8 KiB means binary.
137
+ def text?(path)
138
+ File.open(path, "rb") { |f| !f.read(8192).to_s.include?("\0") }
139
+ rescue Errno::ENOENT, Errno::EACCES
140
+ false
141
+ end
142
+ end
143
+ end
data/lib/twin/scanner.rb CHANGED
@@ -6,7 +6,7 @@ require_relative "remote"
6
6
  module Twin
7
7
  # One YAML block from a sync-file, enriched with live filesystem state.
8
8
  Job = Struct.new(
9
- :program, :path, :description, :active, :excludes, :label,
9
+ :program, :path, :description, :active, :excludes, :owned, :label,
10
10
  :source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
11
11
  :source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
12
12
  :target_unreachable,
@@ -16,6 +16,10 @@ module Twin
16
16
  def target_path = File.join(target, target_path_field || path)
17
17
  def remote? = Twin::Remote.remote?(target)
18
18
 
19
+ # Everything rsync must not touch: Exclude (not part of the sync at all)
20
+ # plus Own (part of the scope, but the target owns it).
21
+ def all_excludes = excludes + (owned || [])
22
+
19
23
  def status
20
24
  return :disabled if active != 1
21
25
  return :unreachable if target_unreachable
@@ -143,7 +147,12 @@ module Twin
143
147
  return nil if path.empty? || source.empty? || target.empty?
144
148
 
145
149
  render = r["Render"] == true
146
- excludes = (r["Exclude"] || "").split(",").map(&:strip).reject(&:empty?)
150
+ excludes = split_list(r["Exclude"])
151
+ # Own: paths inside the sync scope that the TARGET owns — machine-specific
152
+ # config the source must never clobber. Same rsync effect as Exclude, but
153
+ # kept apart so `status` can name the intent instead of hiding it among
154
+ # build artefacts and .DS_Store.
155
+ owned = split_list(r["Own"])
147
156
  remote = Twin::Remote.remote?(target)
148
157
 
149
158
  if render && remote
@@ -171,6 +180,7 @@ module Twin
171
180
  description: r["Description"].to_s,
172
181
  active: (r["Active"] || 0).to_i,
173
182
  excludes: excludes,
183
+ owned: owned,
174
184
  label: r["Label"].to_s,
175
185
  source: source,
176
186
  target: target,
@@ -196,6 +206,11 @@ module Twin
196
206
  [false, nil]
197
207
  end
198
208
 
209
+ # Comma-separated block field → array of trimmed, non-empty entries.
210
+ def split_list(value)
211
+ (value || "").split(",").map(&:strip).reject(&:empty?)
212
+ end
213
+
199
214
  # For a render job: is the target out of date with the rendered template?
200
215
  # nil when source is missing/a directory (status falls through to those).
201
216
  # True when target is absent or content differs, or the template can't be
data/lib/twin/sync.rb CHANGED
@@ -85,7 +85,7 @@ module Twin
85
85
 
86
86
  # Sync one Job. Returns [success, combined_output, transferred].
87
87
  # transferred is true when rsync actually moved bytes (false on no-op or dry_run).
88
- def run_job(cfg, job, dry_run: false)
88
+ def run_job(cfg, job, dry_run: false, force: false)
89
89
  return render_job(cfg, job, dry_run: dry_run) if job.render
90
90
 
91
91
  src = job.source_path
@@ -102,12 +102,12 @@ module Twin
102
102
  FileUtils.mkdir_p(File.dirname(tgt))
103
103
  end
104
104
 
105
- output, status = run(rsync_args(cfg, job, dry_run: dry_run))
105
+ output, status = run(rsync_args(cfg, job, dry_run: dry_run, force: force))
106
106
  return [false, output, false] unless status.success?
107
107
 
108
108
  xfr = !dry_run && transferred?(output)
109
109
 
110
- if job.conflict && !xfr && !dry_run
110
+ if job.conflict && !xfr && !dry_run && !force
111
111
  output += "\nskipped: target is newer, source not synced"
112
112
  end
113
113
 
@@ -128,18 +128,23 @@ module Twin
128
128
  end
129
129
 
130
130
  # Full rsync argument vector for a job.
131
- def rsync_args(cfg, job, dry_run: false)
131
+ #
132
+ # force: drop --update, so a file that is newer on the target is overwritten
133
+ # anyway. Only ever set after the user agreed to it (see Twin::Conflict), or
134
+ # via `twin sync --force`.
135
+ def rsync_args(cfg, job, dry_run: false, force: false)
132
136
  src = job.source_path
133
137
  tgt = job.target_path
134
138
 
135
- args = ["rsync", "-av", "--itemize-changes", "--update"]
139
+ args = ["rsync", "-av", "--itemize-changes"]
140
+ args << "--update" unless force
136
141
  if job.delete
137
142
  args << "--delete"
138
143
  args.concat(backup_args(job))
139
144
  end
140
145
  args << "--dry-run" if dry_run
141
146
  cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
142
- job.excludes.each { |ex| args << "--exclude=#{ex}" }
147
+ job.all_excludes.each { |ex| args << "--exclude=#{ex}" }
143
148
 
144
149
  if File.directory?(src)
145
150
  args << "#{src}/" << "#{tgt}/"
@@ -166,8 +171,8 @@ module Twin
166
171
  end
167
172
 
168
173
  # Sync all jobs in a Program. Returns array of [job, success, output].
169
- def run_program(cfg, program, dry_run: false)
170
- program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
174
+ def run_program(cfg, program, dry_run: false, force: false)
175
+ program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run, force: force)] }
171
176
  end
172
177
 
173
178
  def run(args)
data/lib/twin/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Twin
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/twin.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "twin/template"
4
4
  require_relative "twin/config"
5
5
  require_relative "twin/scanner"
6
6
  require_relative "twin/sync"
7
+ require_relative "twin/conflict"
7
8
  require_relative "twin/journal"
8
9
  require_relative "twin/add"
9
10
  require_relative "twin/preview"
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ralf Hülsmann
@@ -40,6 +40,7 @@ files:
40
40
  - lib/twin/add.rb
41
41
  - lib/twin/cli.rb
42
42
  - lib/twin/config.rb
43
+ - lib/twin/conflict.rb
43
44
  - lib/twin/journal.rb
44
45
  - lib/twin/picker.rb
45
46
  - lib/twin/preview.rb
@@ -81,7 +82,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
82
  requirements: []
82
83
  rubygems_version: 4.0.4
83
84
  specification_version: 4
84
- summary: Sync configuration folders between two Macs from self-documenting Markdown
85
- files
85
+ summary: Sync configuration between machines, from self-documenting Markdown files
86
86
  test_files: []
87
87
  ...