From fa10dd234910e53587c4f20fd622ef2f446a6960 Mon Sep 17 00:00:00 2001 From: uzulla Date: Fri, 5 Jun 2026 10:48:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E3=83=9B=E3=83=BC=E3=83=A0/?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=88=E3=81=A7=E3=82=BB=E3=83=83=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=81=8C=E7=84=A1=E3=81=8F=E3=81=A6=E3=82=82?= =?UTF-8?q?shpool=E3=82=92=E8=B5=B7=E5=8B=95=E3=81=A7=E3=81=8D=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/shp/main.go | 10 +++++++-- internal/session/name.go | 19 +++++++++++++++++ internal/session/name_test.go | 40 ++++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/cmd/shp/main.go b/cmd/shp/main.go index 9e1a382..7adf978 100644 --- a/cmd/shp/main.go +++ b/cmd/shp/main.go @@ -111,9 +111,15 @@ func runPicker() error { sessions = nil } + // The home directory (and the filesystem root) yields no cwd-derived name. + // With no existing sessions to pick from either, fall back to a name + // derived from the full path so `shp` still launches shpool instead of + // dead-ending. When sessions do exist, the picker lists them as before. if cwdName == "" && len(sessions) == 0 { - fmt.Fprintln(os.Stderr, "No shpool sessions found.") - return nil + cwdName, err = session.FallbackName() + if err != nil { + return err + } } picked, err := tui.SelectWithDefault(sessions, cwdName) diff --git a/internal/session/name.go b/internal/session/name.go index 3798644..a507734 100644 --- a/internal/session/name.go +++ b/internal/session/name.go @@ -20,6 +20,25 @@ func FromCwd() (string, error) { return FromPath(cwd, home), nil } +// FallbackName returns a session name to use when FromCwd yields no name, +// which happens in the home directory itself or at the filesystem root. It +// derives the name from the full absolute path (without stripping the home +// prefix), so the home directory maps to e.g. "home.developer" rather than +// dead-ending. This keeps the name unique and consistent with FromPath's +// naming rules. It falls back to "shell" only when even the full path yields +// nothing (the filesystem root). +func FallbackName() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get cwd: %w", err) + } + name := FromPath(cwd, "") + if name == "" { + name = "shell" + } + return name, nil +} + // FromPath converts an absolute path to a session name. // If home is non-empty and path is under home, the home prefix is stripped first. func FromPath(path, home string) string { diff --git a/internal/session/name_test.go b/internal/session/name_test.go index ab2211b..1557dc9 100644 --- a/internal/session/name_test.go +++ b/internal/session/name_test.go @@ -1,6 +1,9 @@ package session -import "testing" +import ( + "os" + "testing" +) func TestFromPath(t *testing.T) { cases := []struct { @@ -89,6 +92,41 @@ func TestFromPath_JapaneseLen(t *testing.T) { } } +func TestFallbackName_DerivesFromFullPath(t *testing.T) { + t.Chdir(t.TempDir()) + + got, err := FallbackName() + if err != nil { + t.Fatalf("FallbackName() error: %v", err) + } + // The full (home-prefix-unstripped) path is used, so the name must match + // FromPath with no home, never be empty, and reflect the directory. + // Resolve the working directory via Getwd (TempDir may contain symlinks). + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd error: %v", err) + } + want := FromPath(dir, "") + if got != want { + t.Errorf("FallbackName() = %q, want %q", got, want) + } + if got == "" { + t.Errorf("FallbackName() returned empty name for %q", dir) + } +} + +func TestFallbackName_RootFallsBackToShell(t *testing.T) { + t.Chdir("/") + + got, err := FallbackName() + if err != nil { + t.Fatalf("FallbackName() error: %v", err) + } + if got != "shell" { + t.Errorf("FallbackName() at root = %q, want %q", got, "shell") + } +} + func TestFromPath_DisambiguatesSanitizedCollisions(t *testing.T) { gotSpace := FromPath("/Users/u/foo bar", "/Users/u") gotUnderscore := FromPath("/Users/u/foo_bar", "/Users/u") From c1a16155bfccb054f76558feba1c558971e36fd5 Mon Sep 17 00:00:00 2001 From: uzulla Date: Fri, 5 Jun 2026 11:00:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20-f/--print-name=20=E3=82=82=E3=83=9B?= =?UTF-8?q?=E3=83=BC=E3=83=A0/=E3=83=AB=E3=83=BC=E3=83=88=E3=81=A7?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E5=90=8D=E3=82=92=E4=BD=BF=E3=81=84=E6=8C=99=E5=8B=95=E3=82=92?= =?UTF-8?q?=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘(Codex)対応: - FromCwdOrFallback() を追加し、picker/-f/--print-name を共通化 - ホーム/ルートで -f がエラー・--print-name が空になる不一致を解消 - DEV.md を新挙動に合わせて更新 - ホームでの実トリガ(FromCwd()=="")を直接押さえるテストを追加 --- DEV.md | 8 ++++--- cmd/shp/main.go | 30 +++++++++++--------------- internal/session/name.go | 15 +++++++++++++ internal/session/name_test.go | 40 +++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/DEV.md b/DEV.md index 1ddb897..5f52130 100644 --- a/DEV.md +++ b/DEV.md @@ -101,6 +101,8 @@ internal/tui/select_test.go | `/Users/uzulla/プロジェクト/api` | `______.api-f10f1a6e` (非ASCIIは1文字あたり `_` + suffix) | | `/srv/app-v1.2.3/api` | `srv.app-v1.2.3.api-597dbea5` | +ホームディレクトリそのもの(相対パスが空になる)やファイルシステムのルートでは相対パス由来の名前が空になる。この場合は `session.FallbackName()` が `$HOME` を取り除かないフルパスから名前を生成する(例: `/home/developer` → `home.developer`)。フルパスでも空になるルート `/` のみ `shell` に固定フォールバックする。`shp` (引数なし) / `shp -f` / `shp --print-name` はいずれも `session.FromCwdOrFallback()` を通すので、ホーム/ルートでも空にならず一貫して起動・出力できる。 + MVP ではこのルールは固定。将来は `~/work` や `~/src` を root として相対化するなどの設定を追加できる設計にしてある (`session.FromPath(path, home)` で home を引数化済み)。 ## attach の実行方式 @@ -118,7 +120,7 @@ attach には常に `--dir .` を渡す。既存セッションへの attach で - 空行を除外 - `NAME STATUS` のヘッダ行を除外 - 各行の先頭カラムをセッション名として扱う -- 候補が0件なら標準エラーに `No shpool sessions found.` を表示して終了 +- 既存セッションが0件でも、cwd 由来名(空ならフルパス由来のフォールバック名)を既定候補として picker を表示し、`shp` がそのまま起動できるようにする ## TUI @@ -147,8 +149,8 @@ MVP では fuzzy / negative / 正規表現 / 複数選択 / preview pane / sort | 状況 | 出力 | 終了コード | | --- | --- | --- | | `shpool` が PATH に無い | `Error: shpool command not found in PATH. Please install shpool first` | 1 | -| `shpool list` で一覧取得不可 | cwd 候補だけを表示。cwd 名も空なら `No shpool sessions found.` | 0 | +| `shpool list` で一覧取得不可 | cwd 候補(空ならフォールバック名)だけを表示 | 0 | | `shpool list` 失敗 (一覧取得不可として扱わないもの) | `Error: shpool list failed: ` | 1 | -| セッション0件 | `No shpool sessions found.` (stderr) | 0 | +| セッション0件 | cwd 候補(空ならフォールバック名)だけの picker を表示 | 0 | | TUI でキャンセル | (何も出さない) | 0 | | 不正な引数 | `Error: too many arguments` 等 + usage | 1 | diff --git a/cmd/shp/main.go b/cmd/shp/main.go index 7adf978..dca7b0a 100644 --- a/cmd/shp/main.go +++ b/cmd/shp/main.go @@ -30,6 +30,9 @@ Usage: shp -h | --help Show this help. New sessions are created in the current directory. +In the home directory (or filesystem root), where no name can be derived from +a relative path, the session name falls back to one derived from the full path +(e.g. "home."), so shp still launches instead of dead-ending. shpool must be installed and on PATH. ` @@ -64,7 +67,7 @@ func run(args []string) error { if len(rest) > 0 { return fmt.Errorf("--print-name takes no arguments") } - name, err := session.FromCwd() + name, err := session.FromCwdOrFallback() if err != nil { return err } @@ -81,15 +84,13 @@ func run(args []string) error { return fmt.Errorf("too many arguments") } - // `shp -f` without a name: force-attach directly to the cwd-derived name. + // `shp -f` without a name: force-attach directly to the cwd-derived name, + // or the full-path fallback when the cwd yields none (home / root). if force { - name, err := session.FromCwd() + name, err := session.FromCwdOrFallback() if err != nil { return err } - if name == "" { - return fmt.Errorf("could not derive a session name from the current directory") - } return shpool.Attach(name, true) } @@ -98,7 +99,11 @@ func run(args []string) error { } func runPicker() error { - cwdName, err := session.FromCwd() + // Use the full-path fallback when the cwd yields no name (the home + // directory itself or the filesystem root) so the picker always has a + // default create-entry, just like any other directory, instead of + // dead-ending with "no sessions". + cwdName, err := session.FromCwdOrFallback() if err != nil { return err } @@ -111,17 +116,6 @@ func runPicker() error { sessions = nil } - // The home directory (and the filesystem root) yields no cwd-derived name. - // With no existing sessions to pick from either, fall back to a name - // derived from the full path so `shp` still launches shpool instead of - // dead-ending. When sessions do exist, the picker lists them as before. - if cwdName == "" && len(sessions) == 0 { - cwdName, err = session.FallbackName() - if err != nil { - return err - } - } - picked, err := tui.SelectWithDefault(sessions, cwdName) if err != nil { if errors.Is(err, tui.ErrCancelled) { diff --git a/internal/session/name.go b/internal/session/name.go index a507734..8000597 100644 --- a/internal/session/name.go +++ b/internal/session/name.go @@ -20,6 +20,21 @@ func FromCwd() (string, error) { return FromPath(cwd, home), nil } +// FromCwdOrFallback returns the cwd-derived session name, falling back to +// FallbackName when the cwd yields no name (the home directory itself or the +// filesystem root). The result is always non-empty, so callers can attach or +// print a name without dead-ending. +func FromCwdOrFallback() (string, error) { + name, err := FromCwd() + if err != nil { + return "", err + } + if name != "" { + return name, nil + } + return FallbackName() +} + // FallbackName returns a session name to use when FromCwd yields no name, // which happens in the home directory itself or at the filesystem root. It // derives the name from the full absolute path (without stripping the home diff --git a/internal/session/name_test.go b/internal/session/name_test.go index 1557dc9..d6ce463 100644 --- a/internal/session/name_test.go +++ b/internal/session/name_test.go @@ -127,6 +127,46 @@ func TestFallbackName_RootFallsBackToShell(t *testing.T) { } } +func TestFromCwdOrFallback_AtHomeUsesFallback(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory: %v", err) + } + t.Chdir(home) + + // The real trigger for the fallback: at home, FromCwd derives nothing. + if got, _ := FromCwd(); got != "" { + t.Fatalf("FromCwd() at home = %q, want empty (precondition for fallback)", got) + } + + got, err := FromCwdOrFallback() + if err != nil { + t.Fatalf("FromCwdOrFallback() error: %v", err) + } + if got == "" { + t.Errorf("FromCwdOrFallback() at home = %q, want non-empty", got) + } + if want, _ := FallbackName(); got != want { + t.Errorf("FromCwdOrFallback() = %q, want FallbackName() = %q", got, want) + } +} + +func TestFromCwdOrFallback_OutsideHomeUsesCwdName(t *testing.T) { + t.Chdir(t.TempDir()) + + cwd, _ := FromCwd() + if cwd == "" { + t.Skip("temp dir derived an empty name; cannot assert passthrough") + } + got, err := FromCwdOrFallback() + if err != nil { + t.Fatalf("FromCwdOrFallback() error: %v", err) + } + if got != cwd { + t.Errorf("FromCwdOrFallback() = %q, want FromCwd() = %q", got, cwd) + } +} + func TestFromPath_DisambiguatesSanitizedCollisions(t *testing.T) { gotSpace := FromPath("/Users/u/foo bar", "/Users/u") gotUnderscore := FromPath("/Users/u/foo_bar", "/Users/u")