Skip to content

Commit 9c36fbd

Browse files
authored
v1.3.2: Skip-Family fix (#8) + Reconnecting UX polish (#9)
* docs: design spec for conflict skip-family fix (#8) Spec for the fix to issue #8: 'Always skip on this iPhone' currently only ignores the original path, so future sync-conflict copies bypass the filter and conflicts reappear. Design adds a paired ignore pattern (original + sync-conflict-* glob), actively removes existing conflict copies, and groups the pair as a single row in the Sync Filters list. * docs: implementation plan for conflict skip-family fix (#8) * feat(bridge): add RemoveConflictFilesForOriginal for skip cleanup Removes every sync-conflict copy of a given file in a folder, leaving the original on disk. Used by the 'Always skip on this iPhone' flow to actively clear conflict-copy leftovers when the user opts out of a file. * feat(ios): wrap RemoveConflictFilesForOriginal in SyncBridgeService * feat(ios): add conflictGlob helper and SkipFamilyGrouping Pure helpers shared between the conflict-resolver Skip flow and the Sync Filters list rendering. Tests cover root files, nested paths, extension-less filenames, mixed-extension stems, and pair detection. * feat(ios): add skipFileAndCleanupConflicts to SyncthingManager Single-call API for the conflict-resolver Skip flow. Writes the pair of ignore patterns, deletes existing conflict copies on disk, rescans, and refreshes the conflict cache. Returns the number of removed conflict copies so the UI can surface it in the success alert. * feat(ios): wire ConflictDiffView Skip to skipFileAndCleanupConflicts Replace the single-pattern addIgnorePattern call with the full Skip flow. Confirmation alert now mentions conflict copies and, when any existed on disk, the number that were removed. Dismisses the diff view on OK since the conflict no longer exists. * feat(ios): group Skip-Family pairs as one row in Sync Filters A paired '<X>' + '<X>.sync-conflict-*' renders as a single Custom Patterns entry with a '+ conflict copies' caption. Swipe-to-delete removes both lines from .stignore atomically. Orphan singletons keep their existing single-line rendering. * feat(ios): localize Skip-Family alert and Sync Filters caption Adds the new 'conflict copies' alert variant plus singular/plural variants of the removed-count sentence and the '+ conflict copies' caption used by the Sync Filters list, for en / de / zh-Hans. * chore: bump to 1.3.2 (build 24) * docs: changelog entry for 1.3.2 (skip-family fix, #8) * docs: update sync-filters UX spec for skip-family (#8) * chore: stop tracking docs/superpowers (internal development notes) * docs: update README What's New to v1.3.2 * feat(ios): track reconnect grace period for required devices Add a per-device 'first observed disconnected' timestamp. Within a 30s window the device counts as 'reconnecting' (calm dashboard state, no warning); after the window it surfaces as a real disconnect via the existing Sync-Issues path. Single entry point applyDeviceList(_:) keeps the timestamp dictionary consistent with the devices array. * feat(ios): show 'Reconnecting…' dashboard state during grace period Insert a new branch in syncStatusText / syncStatusIcon / syncStatusColor that triggers when reconnectingRequiredDeviceIDs is non-empty. Dashboard icon swaps to a system ProgressView spinner for the iOS-native look familiar from pull-to-refresh, and a 'Restoring connection to N device(s)' caption appears under the status text. * feat(ios): localize Reconnecting dashboard state (en/de/zh-Hans) Adds the three new keys consumed by the dashboard reconnecting branch. * docs: add reconnecting-grace-period note to 1.3.2 changelog * docs: add language specifier to sync-filters-ux fenced code block * fix(ios): gate reconnecting UI behind status precedence If currentSyncError != nil OR !isRunning OR foldersWithErrors is non-empty, the dashboard's syncStatusText already shows a higher-priority state ('Error', 'Starting…', 'Sync Issue'). The reconnecting spinner + 'Restoring connection to N device(s)' caption used to render anyway, producing an incoherent row (e.g. red 'Error' text with a teal reconnect spinner). shouldShowReconnectingUI mirrors the syncStatusText cascade so the reconnecting visuals only appear when reconnecting is actually the status being reported. * fix(ios): guard Skip-Family pairing against wildcard / directory rules A user-added .stignore entry like '*.tmp' or 'drafts/' would, via SyncthingManager.conflictGlob, produce a derived glob ('*.sync-conflict-*', 'drafts.sync-conflict-*') that could coincidentally match another manual entry and produce a spurious pairing. The result would be swipe-delete removing both unrelated patterns at once. Add isConcreteFilePathPattern guard that excludes lines containing glob metacharacters (*, ?, [], {}, !) or a trailing slash. Apply in both the pre-pass and the main emission loop. Three new regression tests cover '*.tmp', 'drafts/', and 'note?.md'. * fix(ios): propagate cleanup and rescan failures from skipFileAndCleanupConflicts Previously errors from removeConflictFilesForOriginal and rescanFolder were silently swallowed: the call returned success even when on-disk conflict copies couldn't be removed or the rescan failed. The user saw a 'Skipping enabled' alert while the conflict actually still surfaced on the home screen, with no signal what went wrong. Now both bridge errors are mapped to a SyncUserError and returned to the caller, so the existing 'Could not add filter' alert path surfaces a real message. The .stignore write still happens before the cleanup, so on partial failure the file is at least correctly ignored — the error message tells the user the leftover copies weren't removed. * fix(ios): dispatch skipThisFile via Task so the button handler returns immediately Honest scope note: this only wraps the call in a Task and returns the button handler synchronously. The underlying skipFileAndCleanupConflicts is still @MainActor-isolated (it touches manager state, refreshConflicts, etc.) so the file I/O continues to run on the main actor. A full move to a background executor would require splitting the bridge cleanup, rescan, and refresh paths into nonisolated entry points — out of scope for this PR. The Task wrapper is a non-regressing first step and makes that future refactor strictly easier.
1 parent 4af820f commit 9c36fbd

18 files changed

Lines changed: 955 additions & 50 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ appstore/
4545

4646
# Internal process docs
4747
docs/release-checklist.md
48+
docs/superpowers/

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to VaultSync are documented here.
44

55
---
66

7+
## [1.3.2] — 2026-05-23
8+
9+
### Fixed
10+
11+
- **Skip on iPhone now actually skips returning conflicts** ([#8](https://github.com/psimaker/vaultsync/issues/8)) — Tapping "Always skip on this iPhone" in the conflict resolver previously added only the original file's path to `.stignore`, so a fresh `sync-conflict-…` copy with a new timestamp would arrive from the desktop and the conflict reappeared. The Skip flow now also writes a `<path>.sync-conflict-*` glob, deletes any conflict copies of the file already sitting in the vault, and rescans so the conflict disappears from the Sync Issues list immediately. The Sync Filters → Custom Patterns list groups the pair as a single row with a "+ conflict copies" caption.
12+
- **"Reconnecting…" replaces the transient "device disconnected" warning** — When VaultSync resumes after the app has been away from the foreground for a while, the home screen no longer briefly flashes a "1 Required Device Is Disconnected" warning while the embedded Syncthing process is still rebuilding its connection. Instead, the sync status reads "Reconnecting…" with a calm system spinner for up to 30 seconds. If the peer is genuinely offline beyond that window, the existing warning surfaces normally.
13+
14+
---
15+
716
## [1.3.1] — 2026-05-17
817

918
### Fixed

‎README.md‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,9 @@ VaultSync is also not a magic always-on Syncthing daemon for iOS. Apple’s back
9999

100100
---
101101

102-
## What’s New — v1.3.1
102+
## What’s New — v1.3.2
103103

104-
> **Conflict button no longer also opens the browser** — Tapping "Resolve Conflicts" on the home screen now just opens the conflict list, instead of also launching Safari at the same time.
105-
>
106-
> **Cleaner home screen and settings** — The "VaultSync" page title is gone, devices show just their name, and Settings no longer surfaces per-device rows for relay peers that are already provisioned.
107-
>
108-
> **German and Simplified Chinese fixes** — The Sync Issues titles ("1 Konflikt muss gelöst werden", "1 个冲突待解决", …) are now translated instead of falling back to English.
104+
> **Skip on iPhone now actually skips returning conflicts** — Tapping "Always skip on this iPhone" in the conflict resolver previously added only the original file's path to the ignore list, so a fresh sync-conflict copy with a new timestamp would arrive from the desktop and the conflict reappeared. Skip now also covers future conflict copies of the same file, removes any existing copies on disk, and groups the pair as a single row in Sync Filters.
109105
110106
See [CHANGELOG.md](CHANGELOG.md) for full details.
111107

‎docs/sync-filters-ux.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Sync Filters — UX Spec
22

33
> Status: **implemented** (issue [#1](https://github.com/psimaker/vaultsync/issues/1), shipped in v1.2.0)
4-
> Last updated: 2026-05-09
4+
> Last updated: 2026-05-23
55
66
This document is the design reference for the Sync Filters feature — the UI for excluding files and folders from sync requested in issue #1 by @vitaly74. It captures the rationale behind the layout, preset catalog, migration path, and multi-vault behavior; refer to it when extending or modifying the feature.
77

@@ -116,18 +116,30 @@ The Recommended set is also auto-applied silently when a new folder is added (so
116116

117117
## 6. Conflict → Ignore
118118

119-
In `ConflictDiffView`, a new toolbar menu appears (top-right `⋯`):
119+
In `ConflictDiffView`, a toolbar menu appears (top-right `⋯`):
120120

121-
```
121+
```text
122122
⋯ menu
123123
└─ Always skip on this iPhone
124124
```
125125

126-
Tapping it adds the conflict's *exact relative path* to the folder's ignore list. Then a confirmation alert:
126+
Tapping it performs a **Skip Family** action (added in v1.3.2, see issue [#8](https://github.com/psimaker/vaultsync/issues/8)):
127+
128+
1. Writes a *pair* of patterns to `.stignore`: the file's exact relative path and a matching `<path>.sync-conflict-*` glob.
129+
2. Deletes any sync-conflict copies of that file currently on disk.
130+
3. Rescans the folder and refreshes the conflict cache so the conflict disappears from the home-screen Sync Issues list immediately.
131+
132+
Confirmation alert:
133+
134+
> "`'.obsidian/plugins/dataview/cache.db'` and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters."
135+
136+
If existing conflict copies were removed, a second line is appended:
137+
138+
> "2 existing conflict copies were removed."
127139
128-
> "`'.obsidian/plugins/dataview/cache.db'` will no longer sync to this iPhone. You can undo this in Sync Filters."
140+
Reasoning behind the family approach: the v1.2.0 design used an exact-path pattern for predictability, but that left a hole — a fresh `sync-conflict-…` copy with a new timestamp would arrive from the desktop and the conflict reappeared. Pairing the original path with the conflict-copy glob makes "skip" actually mean skip, without sacrificing predictability: the two `.stignore` lines are still plain, no smart-glob heuristics, no hidden state. In the Sync Filters list the pair is presented as a single row with a `+ conflict copies` caption.
129141

130-
Reasoning behind exact-path (not smart-glob): predictable. The user knows exactly what they ignored. If they later want to widen to `*.cache.db` or `.obsidian/plugins/dataview/*`, they can do that in the editor.
142+
The original file itself is **not** deleted from disk — only the conflict-copy variants. Users who later want to revert can swipe-to-delete the row in Sync Filters; both lines are removed atomically.
131143

132144
## 6.5 Multi-vault setups
133145

‎go/bridge/conflicts.go‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,101 @@ func ResolveConflict(folderID, conflictFileName string, keepConflict bool) strin
243243

244244
return ""
245245
}
246+
247+
// RemoveConflictFilesForOriginal removes every sync-conflict copy of the file
248+
// at originalPath inside the given folder. The original file is NOT touched.
249+
//
250+
// Returns a JSON string of the form:
251+
//
252+
// {"removed": <int>, "error": "<msg or empty>"}
253+
//
254+
// Possible error envelopes: "syncthing not running", "folder not found",
255+
// "invalid path: outside folder root", or "remove <name>: <err>" if an
256+
// individual deletion failed mid-loop.
257+
//
258+
// Symmetric with GetConflictFilesJSON's JSON-return style — keeps the gomobile
259+
// surface uniform (no tuple returns across the bridge).
260+
func RemoveConflictFilesForOriginal(folderID, originalPath string) string {
261+
type result struct {
262+
Removed int `json:"removed"`
263+
Error string `json:"error"`
264+
}
265+
emit := func(r result) string {
266+
data, err := json.Marshal(r)
267+
if err != nil {
268+
return `{"removed":0,"error":"marshal failed"}`
269+
}
270+
return string(data)
271+
}
272+
273+
folders := getFolderConfigs()
274+
if folders == nil {
275+
return emit(result{Error: "syncthing not running"})
276+
}
277+
278+
folder, exists := folders[folderID]
279+
if !exists {
280+
return emit(result{Error: "folder not found"})
281+
}
282+
283+
// Validate the original path is inside the folder root.
284+
absOriginal, err := safePath(folder.Path, originalPath)
285+
if err != nil {
286+
return emit(result{Error: "invalid path: outside folder root"})
287+
}
288+
// Reject paths that resolve to the folder root itself — there is no
289+
// "original file" at the root, and walking its parent would scan
290+
// outside the folder.
291+
if absOriginal == folder.Path {
292+
return emit(result{Error: "invalid path: outside folder root"})
293+
}
294+
295+
dir := filepath.Dir(absOriginal)
296+
baseName := filepath.Base(originalPath)
297+
ext := filepath.Ext(baseName)
298+
stem := strings.TrimSuffix(baseName, ext)
299+
// Common prefix of every conflict copy of this file.
300+
conflictPrefix := stem + ".sync-conflict-"
301+
302+
entries, err := os.ReadDir(dir)
303+
if err != nil {
304+
// If the directory does not exist there are simply no conflicts to remove.
305+
if os.IsNotExist(err) {
306+
return emit(result{Removed: 0})
307+
}
308+
return emit(result{Error: fmt.Sprintf("read dir: %v", err)})
309+
}
310+
311+
removed := 0
312+
for _, e := range entries {
313+
if e.IsDir() {
314+
continue
315+
}
316+
name := e.Name()
317+
if !strings.HasPrefix(name, conflictPrefix) {
318+
continue
319+
}
320+
// Must also match the canonical conflict regex so we only delete real
321+
// Syncthing-generated copies, not user files that happen to share the prefix.
322+
matches := conflictPattern.FindStringSubmatch(name)
323+
if matches == nil {
324+
continue
325+
}
326+
// Defensive: matched stem must equal what we expected.
327+
if matches[1] != stem {
328+
continue
329+
}
330+
// Extension on the conflict copy must equal the original's extension
331+
// (handles files where stem itself contains dots).
332+
if matches[4] != ext {
333+
continue
334+
}
335+
fullPath := filepath.Join(dir, name)
336+
if err := os.Remove(fullPath); err != nil {
337+
return emit(result{Removed: removed, Error: fmt.Sprintf("remove %s: %v", name, err)})
338+
}
339+
removed++
340+
}
341+
342+
return emit(result{Removed: removed})
343+
}

‎go/bridge/conflicts_test.go‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,172 @@ func TestRenameDevice(t *testing.T) {
341341
t.Errorf("rename when stopped = %q, want 'syncthing not running'", errMsg)
342342
}
343343
}
344+
345+
func TestRemoveConflictFilesForOriginal(t *testing.T) {
346+
configDir := testConfigDir(t)
347+
348+
if errMsg := StartSyncthing(configDir); errMsg != "" {
349+
t.Fatalf("StartSyncthing() failed: %s", errMsg)
350+
}
351+
defer StopSyncthing()
352+
353+
folderPath := filepath.Join(configDir, "skipfamily")
354+
if errMsg := AddFolder("skipfamily", "Skip Family", folderPath); errMsg != "" {
355+
t.Fatalf("AddFolder failed: %s", errMsg)
356+
}
357+
358+
// Root-level original + two conflict copies (different timestamps/devices).
359+
if err := os.WriteFile(filepath.Join(folderPath, "notes.md"), []byte("original"), 0o644); err != nil {
360+
t.Fatalf("write notes.md: %v", err)
361+
}
362+
if err := os.WriteFile(filepath.Join(folderPath, "notes.sync-conflict-20260520-120000-AAA1111.md"), []byte("c1"), 0o644); err != nil {
363+
t.Fatalf("write notes conflict c1: %v", err)
364+
}
365+
if err := os.WriteFile(filepath.Join(folderPath, "notes.sync-conflict-20260521-130000-BBB2222.md"), []byte("c2"), 0o644); err != nil {
366+
t.Fatalf("write notes conflict c2: %v", err)
367+
}
368+
369+
// Unrelated file that must not be touched.
370+
if err := os.WriteFile(filepath.Join(folderPath, "other.md"), []byte("other"), 0o644); err != nil {
371+
t.Fatalf("write other.md: %v", err)
372+
}
373+
if err := os.WriteFile(filepath.Join(folderPath, "other.sync-conflict-20260520-120000-CCC3333.md"), []byte("o1"), 0o644); err != nil {
374+
t.Fatalf("write other conflict: %v", err)
375+
}
376+
377+
// Nested original + nested conflict.
378+
subDir := filepath.Join(folderPath, "Personal")
379+
if err := os.MkdirAll(subDir, 0o755); err != nil {
380+
t.Fatalf("mkdir subDir: %v", err)
381+
}
382+
if err := os.WriteFile(filepath.Join(subDir, "diary.md"), []byte("d"), 0o644); err != nil {
383+
t.Fatalf("write diary.md: %v", err)
384+
}
385+
if err := os.WriteFile(filepath.Join(subDir, "diary.sync-conflict-20260520-120000-DDD4444.md"), []byte("d1"), 0o644); err != nil {
386+
t.Fatalf("write diary conflict: %v", err)
387+
}
388+
389+
// Remove conflict copies for "notes.md" only.
390+
got := RemoveConflictFilesForOriginal("skipfamily", "notes.md")
391+
var result struct {
392+
Removed int `json:"removed"`
393+
Error string `json:"error"`
394+
}
395+
if err := json.Unmarshal([]byte(got), &result); err != nil {
396+
t.Fatalf("unmarshal: %v (raw: %s)", err, got)
397+
}
398+
if result.Error != "" {
399+
t.Fatalf("unexpected error: %s", result.Error)
400+
}
401+
if result.Removed != 2 {
402+
t.Errorf("removed = %d, want 2", result.Removed)
403+
}
404+
405+
// Original "notes.md" must survive.
406+
if _, err := os.Stat(filepath.Join(folderPath, "notes.md")); err != nil {
407+
t.Errorf("notes.md should still exist: %v", err)
408+
}
409+
410+
// Both notes conflict copies must be gone.
411+
for _, name := range []string{
412+
"notes.sync-conflict-20260520-120000-AAA1111.md",
413+
"notes.sync-conflict-20260521-130000-BBB2222.md",
414+
} {
415+
if _, err := os.Stat(filepath.Join(folderPath, name)); !os.IsNotExist(err) {
416+
t.Errorf("%s should have been deleted", name)
417+
}
418+
}
419+
420+
// Unrelated "other.*" files must survive.
421+
if _, err := os.Stat(filepath.Join(folderPath, "other.md")); err != nil {
422+
t.Errorf("other.md should still exist: %v", err)
423+
}
424+
if _, err := os.Stat(filepath.Join(folderPath, "other.sync-conflict-20260520-120000-CCC3333.md")); err != nil {
425+
t.Errorf("other.sync-conflict-* should still exist: %v", err)
426+
}
427+
428+
// Nested originals and their conflicts in another directory must survive
429+
// when we ask for the root file only.
430+
if _, err := os.Stat(filepath.Join(subDir, "diary.sync-conflict-20260520-120000-DDD4444.md")); err != nil {
431+
t.Errorf("nested conflict should still exist: %v", err)
432+
}
433+
434+
// Now ask for nested "Personal/diary.md" and verify only the nested copy goes.
435+
got = RemoveConflictFilesForOriginal("skipfamily", filepath.Join("Personal", "diary.md"))
436+
if err := json.Unmarshal([]byte(got), &result); err != nil {
437+
t.Fatalf("unmarshal nested: %v (raw: %s)", err, got)
438+
}
439+
if result.Removed != 1 || result.Error != "" {
440+
t.Errorf("nested call result = %+v, want removed=1 error=\"\"", result)
441+
}
442+
443+
// Dotted-stem regression: archive.tar.gz must match its own conflict copy
444+
// but not a sibling that happens to share the inner stem.
445+
if err := os.WriteFile(filepath.Join(folderPath, "archive.tar.gz"), []byte("a"), 0o644); err != nil {
446+
t.Fatalf("write archive.tar.gz: %v", err)
447+
}
448+
if err := os.WriteFile(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-EEE5555.gz"), []byte("ac"), 0o644); err != nil {
449+
t.Fatalf("write archive conflict copy: %v", err)
450+
}
451+
// Same inner stem but different extension — must NOT match.
452+
if err := os.WriteFile(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-FFF6666.md"), []byte("decoy"), 0o644); err != nil {
453+
t.Fatalf("write decoy: %v", err)
454+
}
455+
456+
got = RemoveConflictFilesForOriginal("skipfamily", "archive.tar.gz")
457+
if err := json.Unmarshal([]byte(got), &result); err != nil {
458+
t.Fatalf("unmarshal dotted: %v (raw: %s)", err, got)
459+
}
460+
if result.Removed != 1 || result.Error != "" {
461+
t.Errorf("dotted-stem call result = %+v, want removed=1 error=\"\"", result)
462+
}
463+
if _, err := os.Stat(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-EEE5555.gz")); !os.IsNotExist(err) {
464+
t.Error("archive.tar.gz conflict copy should have been deleted")
465+
}
466+
if _, err := os.Stat(filepath.Join(folderPath, "archive.tar.sync-conflict-20260520-120000-FFF6666.md")); err != nil {
467+
t.Errorf("decoy with different extension should still exist: %v", err)
468+
}
469+
470+
// Idempotency: running again returns removed=0, no error.
471+
got = RemoveConflictFilesForOriginal("skipfamily", "notes.md")
472+
if err := json.Unmarshal([]byte(got), &result); err != nil {
473+
t.Fatalf("unmarshal idempotent: %v (raw: %s)", err, got)
474+
}
475+
if result.Removed != 0 || result.Error != "" {
476+
t.Errorf("idempotent call = %+v, want removed=0 error=\"\"", result)
477+
}
478+
}
479+
480+
func TestRemoveConflictFilesForOriginalErrors(t *testing.T) {
481+
configDir := testConfigDir(t)
482+
483+
if errMsg := StartSyncthing(configDir); errMsg != "" {
484+
t.Fatalf("StartSyncthing() failed: %s", errMsg)
485+
}
486+
defer StopSyncthing()
487+
488+
folderPath := filepath.Join(configDir, "skipfamilyerr")
489+
if errMsg := AddFolder("skipfamilyerr", "Skip Family Err", folderPath); errMsg != "" {
490+
t.Fatalf("AddFolder failed: %s", errMsg)
491+
}
492+
493+
// Unknown folder.
494+
got := RemoveConflictFilesForOriginal("nonexistent", "x.md")
495+
if !strings.Contains(got, `"error":"folder not found"`) {
496+
t.Errorf("unknown folder result = %q, want error 'folder not found'", got)
497+
}
498+
499+
// Path traversal.
500+
got = RemoveConflictFilesForOriginal("skipfamilyerr", "../../etc/passwd")
501+
if !strings.Contains(got, `"error":"invalid path: outside folder root"`) {
502+
t.Errorf("traversal result = %q, want invalid-path error", got)
503+
}
504+
505+
// Empty / root-equivalent paths must be rejected (would otherwise scan outside folder root).
506+
for _, rp := range []string{"", ".", "/"} {
507+
got := RemoveConflictFilesForOriginal("skipfamilyerr", rp)
508+
if !strings.Contains(got, `"error":"invalid path: outside folder root"`) {
509+
t.Errorf("root path %q result = %q, want invalid-path error", rp, got)
510+
}
511+
}
512+
}

0 commit comments

Comments
 (0)