Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions documentation/docs/features/cross-seed/hardlink-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Reflink mode creates copy-on-write clones of the matched files. Unlike hardlinks
### When to Use Reflink Mode

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

### Reflink Requirements
Expand All @@ -99,7 +99,12 @@ Reflink mode creates copy-on-write clones of the matched files. Unlike hardlinks
- The filesystem must support reflinks:
- **Linux**: BTRFS, XFS (with reflink=1), and similar CoW filesystems
- **macOS**: APFS
- **Windows/FreeBSD**: Not currently supported
- **Windows**: ReFS on the same volume as the source files and reflink base directory
- **FreeBSD**: Not currently supported

:::note
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.
:::

:::tip
On Linux, check the filesystem type with `df -T /path` (you want `xfs`/`btrfs`, not `fuseblk`/`fuse.mergerfs`/`overlayfs`).
Expand Down
39 changes: 39 additions & 0 deletions internal/services/crossseed/hardlink_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package crossseed

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -16,6 +18,7 @@ import (

"github.com/autobrr/qui/internal/models"
"github.com/autobrr/qui/pkg/hardlinktree"
"github.com/autobrr/qui/pkg/reflinktree"
)

// Note: qbtLayoutToHardlinkLayout is no longer used in hardlink mode.
Expand Down Expand Up @@ -904,3 +907,39 @@ func TestProcessReflinkMode_FallbackDisabled(t *testing.T) {
assert.Equal(t, "reflink_error", result.Result.Status)
assert.Contains(t, result.Result.Message, "base directory")
}

func TestShouldWarnForReflinkCreateError(t *testing.T) {
t.Parallel()

tests := []struct {
name string
err error
want bool
}{
{
name: "plain wrapped unsupported error",
err: fmt.Errorf("reflink create failed: %w", reflinktree.ErrReflinkUnsupported),
want: true,
},
{
name: "joined rollback error stays error level",
err: errors.Join(
fmt.Errorf("reflink create failed: %w", reflinktree.ErrReflinkUnsupported),
errors.New("rollback also failed"),
),
want: false,
},
{
name: "unrelated error",
err: errors.New("boom"),
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, shouldWarnForReflinkCreateError(tt.err))
})
}
}
19 changes: 18 additions & 1 deletion internal/services/crossseed/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10861,6 +10861,19 @@ type reflinkModeResult struct {
Result InstanceCrossSeedResult
}

func shouldWarnForReflinkCreateError(err error) bool {
if !errors.Is(err, reflinktree.ErrReflinkUnsupported) {
return false
}

type multiUnwrapper interface {
Unwrap() []error
}

var joined multiUnwrapper
return !errors.As(err, &joined)
}

// processReflinkMode attempts to add a cross-seed torrent using reflink (copy-on-write) mode.
// This creates a reflink tree matching the incoming torrent's layout, allowing safe
// modification of cloned files without affecting originals.
Expand Down Expand Up @@ -11081,7 +11094,11 @@ func (s *Service) processReflinkMode(

// Create reflink tree on disk
if err := reflinktree.Create(plan); err != nil {
log.Error().
logEvent := log.Error()
if shouldWarnForReflinkCreateError(err) {
logEvent = log.Warn()
}
logEvent.
Err(err).
Int("instanceID", candidate.InstanceID).
Str("torrentName", torrentName).
Expand Down
8 changes: 4 additions & 4 deletions pkg/reflinktree/reflink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ func TestSupportsReflink(t *testing.T) {

supported, reason := SupportsReflink(tmpDir)

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

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

if !supported {
Expand Down
4 changes: 2 additions & 2 deletions pkg/reflinktree/reflink_unsupported.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright (c) 2025-2026, s0up and the autobrr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later

//go:build !linux && !darwin
//go:build !linux && !darwin && !windows

package reflinktree

// SupportsReflink returns false on unsupported platforms.
// Windows and FreeBSD do not have a standard reflink mechanism that we support.
// FreeBSD do not have a standard reflink mechanism that we support.
func SupportsReflink(_ string) (supported bool, reason string) {
return false, "reflink is not supported on this operating system"
}
Expand Down
Loading