Skip to content

Commit 7899cc8

Browse files
Audionuts0up4200
andauthored
refactor(reflinking): add windows ReFS filesystem support (#1576)
Co-authored-by: soup <s0up4200@pm.me>
1 parent 51d34ab commit 7899cc8

File tree

7 files changed

+1225
-9
lines changed

7 files changed

+1225
-9
lines changed

documentation/docs/features/cross-seed/hardlink-mode.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ Reflink mode creates copy-on-write clones of the matched files. Unlike hardlinks
8888
### When to Use Reflink Mode
8989

9090
- You want to cross-seed torrents that hardlink mode would skip due to "extra files share pieces with content"
91-
- Your filesystem supports copy-on-write clones (BTRFS, XFS on Linux; APFS on macOS)
91+
- Your filesystem supports copy-on-write clones (BTRFS, XFS on Linux; APFS on macOS; ReFS on Windows)
9292
- You prefer the safety of copy-on-write over hardlinks
9393

9494
### Reflink Requirements
@@ -99,7 +99,12 @@ Reflink mode creates copy-on-write clones of the matched files. Unlike hardlinks
9999
- The filesystem must support reflinks:
100100
- **Linux**: BTRFS, XFS (with reflink=1), and similar CoW filesystems
101101
- **macOS**: APFS
102-
- **Windows/FreeBSD**: Not currently supported
102+
- **Windows**: ReFS on the same volume as the source files and reflink base directory
103+
- **FreeBSD**: Not currently supported
104+
105+
:::note
106+
Windows reflink mode uses ReFS block cloning (requiring a ReFS filesystem). NTFS is not supported. If the matched source path is a symlink, qui resolves it before cloning, and the resolved source plus the reflink base directory still need to be on the same ReFS volume. If reflink creation fails, fallback still depends on the existing "Fallback to regular mode" setting.
107+
:::
103108

104109
:::tip
105110
On Linux, check the filesystem type with `df -T /path` (you want `xfs`/`btrfs`, not `fuseblk`/`fuse.mergerfs`/`overlayfs`).

internal/services/crossseed/hardlink_mode_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package crossseed
55

66
import (
77
"context"
8+
"errors"
9+
"fmt"
810
"os"
911
"path/filepath"
1012
"strings"
@@ -16,6 +18,7 @@ import (
1618

1719
"github.com/autobrr/qui/internal/models"
1820
"github.com/autobrr/qui/pkg/hardlinktree"
21+
"github.com/autobrr/qui/pkg/reflinktree"
1922
)
2023

2124
// Note: qbtLayoutToHardlinkLayout is no longer used in hardlink mode.
@@ -904,3 +907,39 @@ func TestProcessReflinkMode_FallbackDisabled(t *testing.T) {
904907
assert.Equal(t, "reflink_error", result.Result.Status)
905908
assert.Contains(t, result.Result.Message, "base directory")
906909
}
910+
911+
func TestShouldWarnForReflinkCreateError(t *testing.T) {
912+
t.Parallel()
913+
914+
tests := []struct {
915+
name string
916+
err error
917+
want bool
918+
}{
919+
{
920+
name: "plain wrapped unsupported error",
921+
err: fmt.Errorf("reflink create failed: %w", reflinktree.ErrReflinkUnsupported),
922+
want: true,
923+
},
924+
{
925+
name: "joined rollback error stays error level",
926+
err: errors.Join(
927+
fmt.Errorf("reflink create failed: %w", reflinktree.ErrReflinkUnsupported),
928+
errors.New("rollback also failed"),
929+
),
930+
want: false,
931+
},
932+
{
933+
name: "unrelated error",
934+
err: errors.New("boom"),
935+
want: false,
936+
},
937+
}
938+
939+
for _, tt := range tests {
940+
t.Run(tt.name, func(t *testing.T) {
941+
t.Parallel()
942+
assert.Equal(t, tt.want, shouldWarnForReflinkCreateError(tt.err))
943+
})
944+
}
945+
}

internal/services/crossseed/service.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10861,6 +10861,19 @@ type reflinkModeResult struct {
1086110861
Result InstanceCrossSeedResult
1086210862
}
1086310863

10864+
func shouldWarnForReflinkCreateError(err error) bool {
10865+
if !errors.Is(err, reflinktree.ErrReflinkUnsupported) {
10866+
return false
10867+
}
10868+
10869+
type multiUnwrapper interface {
10870+
Unwrap() []error
10871+
}
10872+
10873+
var joined multiUnwrapper
10874+
return !errors.As(err, &joined)
10875+
}
10876+
1086410877
// processReflinkMode attempts to add a cross-seed torrent using reflink (copy-on-write) mode.
1086510878
// This creates a reflink tree matching the incoming torrent's layout, allowing safe
1086610879
// modification of cloned files without affecting originals.
@@ -11081,7 +11094,11 @@ func (s *Service) processReflinkMode(
1108111094

1108211095
// Create reflink tree on disk
1108311096
if err := reflinktree.Create(plan); err != nil {
11084-
log.Error().
11097+
logEvent := log.Error()
11098+
if shouldWarnForReflinkCreateError(err) {
11099+
logEvent = log.Warn()
11100+
}
11101+
logEvent.
1108511102
Err(err).
1108611103
Int("instanceID", candidate.InstanceID).
1108711104
Str("torrentName", torrentName).

pkg/reflinktree/reflink_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ func TestSupportsReflink(t *testing.T) {
1717

1818
supported, reason := SupportsReflink(tmpDir)
1919

20-
// On unsupported platforms, should return false
21-
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
20+
// On unsupported platforms, should return false.
21+
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
2222
if supported {
2323
t.Errorf("SupportsReflink should return false on %s", runtime.GOOS)
2424
}
@@ -28,8 +28,8 @@ func TestSupportsReflink(t *testing.T) {
2828
return
2929
}
3030

31-
// On supported platforms, the result depends on filesystem
32-
// We just verify the function doesn't panic and returns sensible values
31+
// On supported platforms, the result depends on filesystem.
32+
// We just verify the function doesn't panic and returns sensible values.
3333
t.Logf("SupportsReflink(%s): supported=%v, reason=%s", tmpDir, supported, reason)
3434

3535
if !supported {

pkg/reflinktree/reflink_unsupported.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// Copyright (c) 2025-2026, s0up and the autobrr contributors.
22
// SPDX-License-Identifier: GPL-2.0-or-later
33

4-
//go:build !linux && !darwin
4+
//go:build !linux && !darwin && !windows
55

66
package reflinktree
77

88
// SupportsReflink returns false on unsupported platforms.
9-
// Windows and FreeBSD do not have a standard reflink mechanism that we support.
9+
// FreeBSD do not have a standard reflink mechanism that we support.
1010
func SupportsReflink(_ string) (supported bool, reason string) {
1111
return false, "reflink is not supported on this operating system"
1212
}

0 commit comments

Comments
 (0)