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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
uses: actions/checkout@v7
- name: Run tests
run: go test -v -covermode=count ./...
env:
# lets the darwin-only test move a file into the runner's real Trash
GDU_TEST_SYSTEM_TRASH: "1"

coverage:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ Delete items in parallel, which might increase the speed of deletion

#### `trash-command`

Command used by the `D` key to move items to trash, replacing the built-in [FreeDesktop trash](https://specifications.freedesktop.org/trash-spec/latest/) implementation. Useful to trash into a non-default location, or to use a trash tool on systems where the built-in trash is not available (macOS).
Command used by the `D` key to move items to trash, replacing the built-in [FreeDesktop trash](https://specifications.freedesktop.org/trash-spec/latest/) implementation. Useful to trash into a non-default location, or to use a different trash tool. On macOS the built-in trash runs the system `/usr/bin/trash` (macOS 15 and later), which moves items into the Finder trash; on older releases set this option to a trash CLI instead.

The command is evaluated by `/bin/sh` with the absolute path of the item appended as an argument, so shell syntax such as variable expansion works:

Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0002-external-trash-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Date: 2026-09-02

## Status

Accepted
Accepted. The macOS consequence is partly superseded by
[ADR 0003](0003-macos-trash-uses-system-trash-command.md): the built-in trash
now runs `/usr/bin/trash` on macOS.

## Context

Expand Down
42 changes: 42 additions & 0 deletions docs/adr/0003-macos-trash-uses-system-trash-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 3. The macOS trash runs the system trash command

Date: 2026-10-19

## Status

Accepted

## Context

The built-in trash targets the FreeDesktop trash, which Finder ignores, so on
darwin it was a stub that returned "not supported".
[ADR 0002 (external trash command)](0002-external-trash-command.md) left macOS
users a workaround (`--trash-command`) and noted that probing for a trash CLI
remained open.

The web UI's cleanup projections make moving many items to the trash a common
action, so it has to work without configuration on the platform most of its
users run.

macOS 15 (Sequoia) and later ship `/usr/bin/trash`, which moves items into the
Finder trash, where they can be put back. It is missing on macOS 14 and older.

## Decision

On darwin, `MoveItemToTrash` runs `/usr/bin/trash <abspath>` directly, without a
shell. `trash` does not understand `--`, but the path is always absolute, so it
can never be read as an option.

As with the trash command, the tree is updated from the filesystem: the item is
dropped only when its path is really gone.

When `/usr/bin/trash` does not exist, the error points the user at
`trash-command`, which still takes precedence when set.

## Consequences

- `D` in the terminal UI and "Move to trash" in the web UI work on macOS out of
the box, and trashed items can be restored from Finder.
- Older macOS releases without `/usr/bin/trash` keep the previous behavior, an
error, with a hint towards the workaround.
- No Objective-C bridge or cgo is needed.
33 changes: 30 additions & 3 deletions pkg/remove/trash_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,40 @@
package remove

import (
"bytes"
"errors"
"fmt"
"os"

"github.com/dundee/gdu/v5/pkg/fs"
)

// MoveItemToTrash is not supported on macOS. The XDG trash location is ignored
// by Finder, so use permanent delete (d) or empty (e) instead.
// macOSTrashBin is the trash CLI shipped with macOS. It moves items into the
// Finder trash, so they can be put back from there.
var macOSTrashBin = "/usr/bin/trash"

// MoveItemToTrash moves the item into the Finder trash using the system trash
// command. The path is passed as an argument without a shell; it is absolute,
// so it can never be taken for an option. The tree is updated only when the
// path is really gone, as for TrashCommand.
func MoveItemToTrash(dir, item fs.Item) error {
return fmt.Errorf("move to trash is not supported on macOS")
if _, err := os.Stat(macOSTrashBin); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%s not found; set trash-command to a trash CLI instead", macOSTrashBin)
}

absPath, err := trashCommandOS.abs(item.GetPath())
if err != nil {
return err
}

var stderr bytes.Buffer
if err := trashCommandOS.run(macOSTrashBin, []string{absPath}, trashCommandOS.environ(), &stderr); err != nil {
return commandError(macOSTrashBin, err, stderr.String())
}
if _, err := trashCommandOS.lstat(absPath); err == nil {
return nil
}

dir.RemoveFile(item)
return nil
}
76 changes: 73 additions & 3 deletions pkg/remove/trash_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,85 @@
package remove

import (
"io"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/dundee/gdu/v5/pkg/analyze"
)

func TestMoveItemToTrashUnsupportedOnDarwin(t *testing.T) {
err := MoveItemToTrash(nil, nil)
func darwinTrashFixture(t *testing.T) (*analyze.Dir, *analyze.File, string) {
t.Helper()
base := t.TempDir()
path := filepath.Join(base, "-rf file")
require.NoError(t, os.WriteFile(path, []byte("x"), 0o600))

dir := &analyze.Dir{File: &analyze.File{Name: filepath.Base(base)}, BasePath: filepath.Dir(base)}
item := &analyze.File{Name: "-rf file", Parent: dir}
dir.Files = append(dir.Files, item)
return dir, item, path
}

func TestMoveItemToTrashRunsSystemTrash(t *testing.T) {
dir, item, path := darwinTrashFixture(t)

var gotArgv0 string
var gotArgv []string
orig := trashCommandOS.run
trashCommandOS.run = func(argv0 string, argv, _ []string, _ io.Writer) error {
gotArgv0, gotArgv = argv0, argv
return os.Remove(path)
}
defer func() { trashCommandOS.run = orig }()

require.NoError(t, MoveItemToTrash(dir, item))
assert.Equal(t, macOSTrashBin, gotArgv0)
assert.Equal(t, []string{path}, gotArgv)
assert.Empty(t, dir.Files)
}

func TestMoveItemToTrashKeepsItemThatIsStillThere(t *testing.T) {
dir, item, _ := darwinTrashFixture(t)

orig := trashCommandOS.run
trashCommandOS.run = func(string, []string, []string, io.Writer) error { return nil }
defer func() { trashCommandOS.run = orig }()

require.NoError(t, MoveItemToTrash(dir, item))
assert.Len(t, dir.Files, 1)
}

func TestMoveItemToTrashMissingBinary(t *testing.T) {
dir, item, _ := darwinTrashFixture(t)

orig := macOSTrashBin
macOSTrashBin = filepath.Join(t.TempDir(), "no-trash")
defer func() { macOSTrashBin = orig }()

err := MoveItemToTrash(dir, item)
require.Error(t, err)
assert.Contains(t, err.Error(), "not supported on macOS")
assert.Contains(t, err.Error(), "trash-command")
assert.Len(t, dir.Files, 1)
}

// TestMoveItemToTrashWithSystemTrash runs the real /usr/bin/trash, which puts
// the test file into the Trash of whoever runs it, so it only runs when
// GDU_TEST_SYSTEM_TRASH=1 is set (as in CI).
func TestMoveItemToTrashWithSystemTrash(t *testing.T) {
if os.Getenv("GDU_TEST_SYSTEM_TRASH") != "1" {
t.Skip("set GDU_TEST_SYSTEM_TRASH=1 to move a test file into the real Trash")
}
if _, err := os.Stat(macOSTrashBin); err != nil {
t.Skipf("%s is not available: %s", macOSTrashBin, err)
}
dir, item, path := darwinTrashFixture(t)

require.NoError(t, MoveItemToTrash(dir, item))
_, err := os.Lstat(path)
assert.ErrorIs(t, err, os.ErrNotExist)
assert.Empty(t, dir.Files)
}
Loading