summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-08 01:11:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-08 01:11:38 +0200
commited0c680790950354f15fb5835e1d7b213efa1bf8 (patch)
tree59af376652005a0b02c95945c1df6044882a94e5
parent4c8e4fb8b8fcd3f78bf4e3e736a058c351aac973 (diff)
downloadmeshbay-ed0c680790950354f15fb5835e1d7b213efa1bf8.tar.gz
fix(node): name a root after its drive when its basename is taken
Two drives with a folder of the same name made the second add fail, and no screen could supply another name. add_root now names it "Name (H)" or "Name (parent)"; a name the operator typed is still refused on a clash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md9
-rw-r--r--docs/USERGUIDE.md6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/roots.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py21
-rw-r--r--packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py31
-rw-r--r--packages/meshbay-node/tests/test_roots.py16
6 files changed, 90 insertions, 8 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 9decd34..23ced38 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1503,9 +1503,12 @@ stored.** Never recomputed from the path: renaming a folder on disk would
otherwise silently re-identify a whole library and break every stored reference to
it. Four rules make basename naming safe:
-- **A duplicate basename is refused, case-insensitively.** Collisions are common in
- practice (`D:\Films` and `E:\Films`). Refusing is correct; an explicit alias is
- the escape hatch (open item O11).
+- **A duplicate name is refused, case-insensitively.** Collisions are common in
+ practice (`D:\Films` and `E:\Films`), and the interface has no field for a name,
+ so `add_root` gives a folder whose basename is taken a name saying where it is:
+ `Films (E)`, or `Films (b)` for `/mnt/b/Films` (`roots.distinct_name`). Only a
+ name the operator typed is refused on a clash. A drive root, which has no
+ basename at all, still needs an explicit alias (open item O11).
- **No root may contain another**, compared case-insensitively and after
canonicalisation. Two nested roots would index the same bytes twice under two
identities.
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index ea81c76..adca9e3 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -504,8 +504,10 @@ meshbay-node root remove Films
Rules that will bite you if you do not know them:
-- **Two roots cannot share a basename**, even on different drives. `/mnt/a/Films`
- and `/mnt/b/Films` is refused; name one of them explicitly.
+- **Two roots cannot share a name**, even on different drives. When you add
+ `/mnt/b/Films` beside `/mnt/a/Films`, the second one is called `Films (b)`
+ (on Windows, `H:\Films` beside `G:\Films` becomes `Films (H)`). A name you
+ give with `--name` that is already taken is refused.
- **No root inside another.** The same bytes would be indexed twice under two
identities.
- **Renaming a root rewrites every path under it**, so it is an explicit act,
diff --git a/packages/meshbay-node/src/meshbay_node/ops/roots.py b/packages/meshbay-node/src/meshbay_node/ops/roots.py
index b976e8f..c272978 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/roots.py
@@ -14,7 +14,7 @@ from meshbay_node.ops.node_toml import (
_update_root_field,
toml_string,
)
-from meshbay_node.roots import RootError, RootSet, off_disk
+from meshbay_node.roots import RootError, RootSet, distinct_name, off_disk
log = logging.getLogger("meshbay_node.ops")
@@ -36,12 +36,21 @@ async def add_root(state: dict, group_id: str, path: str, *,
raise OpError("Group not configured on this node", status=404)
specs = [asdict(r) for r in cfg.roots]
- specs.append({"path": path, "name": name, "kind": kind,
- "writable": writable, "removable": removable})
try:
+ # Every caller sends the folder's basename when nobody typed a name, so
+ # that is the case that gets a distinct one. A name somebody chose is
+ # still refused on a clash.
+ target = Path(path).expanduser().resolve()
+ if name.strip() in ("", target.name):
+ taken = {r.folded for r in RootSet.build(specs).roots}
+ name = distinct_name(target, taken)
+ specs.append({"path": path, "name": name, "kind": kind,
+ "writable": writable, "removable": removable})
built = RootSet.build(specs)
except RootError as e:
raise OpError(str(e)) from e
+ except OSError as e:
+ raise OpError(f"{path}: {e}") from e
added = built.roots[-1]
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index 2b48292..7dded1f 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -211,6 +211,27 @@ def derive_name(path: Path) -> str:
return name
+def distinct_name(path: Path, taken: set[str]) -> str:
+ """
+ `derive_name(path)`, or, when a root in `taken` (folded names) already has
+ it, that name followed by where the directory is: `Archives (H)` for
+ `H:\\Archives` beside `G:\\Archives`, `Films (b)` for `/mnt/b/Films`.
+
+ The same folder name on two drives is the ordinary case, and the interface
+ has no field to type a name into, so refusing it left no way forward.
+ """
+ base = derive_name(path)
+ candidates = [base]
+ where = path.parent.name or path.drive.rstrip(":")
+ if where:
+ candidates.append(f"{base} ({where})")
+ candidates += [f"{base} ({n})" for n in range(2, 100)]
+ for name in candidates:
+ if fold(name) not in taken and not portable_name_problem(name):
+ return name
+ return base
+
+
@dataclass
class RootSet:
"""
diff --git a/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py b/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py
index b2b3650..1a0d9f1 100644
--- a/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py
+++ b/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py
@@ -235,6 +235,37 @@ async def test_adding_the_same_directory_twice_is_still_refused(tmp_path):
await roster.close()
+@pytest.mark.parametrize("sent_name", ["", "Archives"])
+async def test_the_same_folder_name_on_two_drives_gets_a_name_of_its_own(tmp_path, sent_name):
+ """The picker sends the basename; the second folder must not be refused for it."""
+ state, roster = await _state(tmp_path)
+ for drive in ("g", "h"):
+ (tmp_path / drive / "Archives").mkdir(parents=True)
+ try:
+ first = await ops.add_root(state, GROUP, str(tmp_path / "g" / "Archives"),
+ name=sent_name)
+ second = await ops.add_root(state, GROUP, str(tmp_path / "h" / "Archives"),
+ name=sent_name)
+ assert first["name"] == "Archives"
+ assert second["name"] == "Archives (h)"
+ assert {"Archives", "Archives (h)"} <= set(_rebuilt(state).names), (
+ "the name given to the second folder did not reach node.toml")
+ assert 'name = "Archives (h)"' in Path(state["config_path"]).read_text(
+ encoding="utf-8")
+ finally:
+ await roster.close()
+
+
+async def test_a_name_somebody_chose_is_still_refused_on_a_clash(tmp_path):
+ state, roster = await _state(tmp_path)
+ (tmp_path / "other").mkdir()
+ try:
+ with pytest.raises(ops.OpError, match="both be called"):
+ await ops.add_root(state, GROUP, str(tmp_path / "other"), name="one")
+ finally:
+ await roster.close()
+
+
async def test_a_second_different_root_still_lands(tmp_path):
state, roster = await _state(tmp_path)
(tmp_path / "uploads").mkdir()
diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py
index 9a403ff..0fe9f03 100644
--- a/packages/meshbay-node/tests/test_roots.py
+++ b/packages/meshbay-node/tests/test_roots.py
@@ -8,13 +8,17 @@ copy breaks the other".
"""
+from pathlib import PurePosixPath, PureWindowsPath
+
import pytest
+from meshbay_common.paths import fold
from meshbay_common.protocol import IndexEntry
from meshbay_node.roots import (
SAFE_UPLOAD_NAME,
RootError,
RootSet,
_free_name,
+ distinct_name,
entry_abs_path,
safe_subdir,
)
@@ -51,6 +55,18 @@ def test_two_roots_cannot_share_a_name(tmp_path):
_spec(tmp_path / "b" / "Films")])
+def test_a_distinct_name_says_where_the_folder_is():
+ taken = {fold("Archives")}
+ assert distinct_name(PureWindowsPath(r"H:\Archives"), taken) == "Archives (H)"
+ assert distinct_name(PurePosixPath("/mnt/b/Films"), {fold("Films")}) == "Films (b)"
+ assert distinct_name(PurePosixPath("/mnt/b/Films"), set()) == "Films"
+
+
+def test_a_distinct_name_counts_when_where_is_taken_too():
+ taken = {fold("Films"), fold("Films (b)")}
+ assert distinct_name(PurePosixPath("/mnt/b/Films"), taken) == "Films (2)"
+
+
def test_names_clash_without_regard_to_case(tmp_path):
"""
`Films` and `films` are one directory on NTFS and exFAT, which is where most