From 43061706264d318f383c5b5c67143dad880f801c Mon Sep 17 00:00:00 2001 From: nevaforget Date: Mon, 4 May 2026 10:24:53 +0200 Subject: [PATCH] refactor: delegate -l output to findmnt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom one-alias-per-line output was useless — no mountpoint, no source, no options. Reinventing a table format when findmnt from util-linux already produces a familiar fuse.sshfs view was the wrong call. -l now shells out to findmnt -t fuse.sshfs. --- DECISIONS.md | 30 ++++++++++++++++++++++++++++++ README.md | 3 ++- main.go | 30 +++++++++++++----------------- main_test.go | 31 ------------------------------- 4 files changed, 45 insertions(+), 49 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index ef96091..54327e5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,35 @@ # Decisions +## 2026-05-04 – Delegate `-l` output to `findmnt -t fuse.sshfs` +- **Who**: Dom, ClaudeCode +- **Why**: The custom `-l` output ("just the alias, one per line") was + uselessly minimal — no mountpoint, no source, no options. Reinventing a + table format for a single command was the wrong call when `findmnt` from + `util-linux` already produces a familiar, well-formatted view of fuse.sshfs + mounts. +- **Tradeoffs**: + - External dependency on `findmnt` (part of `util-linux`, present on every + Arch and most other Linux systems by default — no real adoption cost). + - Output is mountpoint-first, not alias-first. The alias is only visible as + the last path segment under the SOURCE column / TARGET basename. + - Lists *all* fuse.sshfs mounts on the system, not just sshfsc-managed + ones under `$XDG_RUNTIME_DIR/sshfs/`. In practice this is what the user + wants ("show me all sshfs mounts"); a future `-r` (restrict) flag could + narrow it if needed. + - `findmnt` exits 1 when nothing matches its filter — we treat that as + success-with-empty-output, not a failure. +- **How**: + - `list_mounts` rewritten as a thin wrapper around `exec.Command("findmnt", + "-t", "fuse.sshfs")` with stdout piped to the caller's writer. + - Exit-code 1 from `findmnt` is swallowed via `errors.As(err, *exec.ExitError)` + + `ExitCode() == 1`. + - `errors.Is(err, exec.ErrNotFound)` produces a clear "install util-linux" + message instead of a cryptic exec error. + - Tests `TestListMountsMissingBase` and `TestListMountsFiltersUnmounted` + removed: they covered the old in-Go enumeration logic that no longer + exists. Mocking `findmnt` would be alibi-testing — the wrapper is two + branches over `cmd.Run()`'s error. + ## 2026-05-04 – Detect mounts via `/proc/self/mountinfo` instead of `stat` - **Who**: Dom, ClaudeCode - **Why**: `mountinfo.Mounted(path)` from `github.com/moby/sys/mountinfo` works diff --git a/README.md b/README.md index b5d97d0..11cab79 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ install -Dm755 sshfsc /usr/local/bin/sshfsc # Dependencies - [SSHFS](https://wiki.archlinux.org/title/SSHFS) +- `findmnt` from `util-linux` (for `-l`) - [Go](https://wiki.archlinux.org/title/Go) >= 1.25 (build-time) # Usage @@ -39,7 +40,7 @@ sshfsc -l # list active mounts | `-e` | open mountpoint in your editor | | `-v` | verbose: print resolved ssh_config fields (HostName, User, Port, IdentityFile) | | `-r`, `--remote-dir ` | remote directory to mount (default: remote home) | -| `-l` | list active mounts under `$XDG_RUNTIME_DIR/sshfs/` and exit | +| `-l` | list active fuse.sshfs mounts via `findmnt` and exit | | `-u` | unmount the given `` and exit (mutually exclusive with `-l`) | By default only the resolved mount path is printed. Use `-v` for the full diff --git a/main.go b/main.go index 2963e91..38b06c5 100644 --- a/main.go +++ b/main.go @@ -281,26 +281,22 @@ func is_mounted_at(path string) (bool, error) { } func list_mounts(out io.Writer) error { - base, err := mount_base(false) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return err + cmd := exec.Command("findmnt", "-t", "fuse.sshfs") + cmd.Stdout = out + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil { + return nil } - mounts, err := mountinfo.GetMounts(mountinfo.PrefixFilter(base)) - if err != nil { - return fmt.Errorf("read mountinfo: %w", err) + // findmnt exits 1 when no matching mount exists — not an error here. + var ee *exec.ExitError + if errors.As(err, &ee) && ee.ExitCode() == 1 { + return nil } - prefix := base + string(os.PathSeparator) - for _, m := range mounts { - rel := strings.TrimPrefix(m.Mountpoint, prefix) - if rel == "" || strings.Contains(rel, string(os.PathSeparator)) { - continue - } - fmt.Fprintln(out, rel) + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("findmnt not found (install util-linux)") } - return nil + return fmt.Errorf("findmnt: %w", err) } func run_fusermount(flag, mount string) error { diff --git a/main_test.go b/main_test.go index 858aa2e..0300751 100644 --- a/main_test.go +++ b/main_test.go @@ -4,7 +4,6 @@ package main import ( - "bytes" "os" "path/filepath" "strings" @@ -140,36 +139,6 @@ func TestVerifyMountDirRejectsSymlink(t *testing.T) { } } -func TestListMountsMissingBase(t *testing.T) { - runtime := t.TempDir() - t.Setenv("XDG_RUNTIME_DIR", runtime) - var buf bytes.Buffer - if err := list_mounts(&buf); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if buf.Len() != 0 { - t.Fatalf("want empty output, got %q", buf.String()) - } -} - -func TestListMountsFiltersUnmounted(t *testing.T) { - runtime := t.TempDir() - t.Setenv("XDG_RUNTIME_DIR", runtime) - base := filepath.Join(runtime, "sshfs") - for _, name := range []string{"stale1", "stale2"} { - if err := os.MkdirAll(filepath.Join(base, name), 0700); err != nil { - t.Fatalf("setup stale dir: %v", err) - } - } - var buf bytes.Buffer - if err := list_mounts(&buf); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if buf.Len() != 0 { - t.Fatalf("want stale dirs filtered, got %q", buf.String()) - } -} - func TestUnmountRejectsBadAlias(t *testing.T) { runtime := t.TempDir() t.Setenv("XDG_RUNTIME_DIR", runtime)