sandling 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sandling/__init__.py +3 -0
- sandling/__main__.py +3 -0
- sandling/cli.py +190 -0
- sandling/service.py +491 -0
- sandling-0.1.0.dist-info/METADATA +137 -0
- sandling-0.1.0.dist-info/RECORD +10 -0
- sandling-0.1.0.dist-info/WHEEL +5 -0
- sandling-0.1.0.dist-info/entry_points.txt +2 -0
- sandling-0.1.0.dist-info/licenses/LICENSE +21 -0
- sandling-0.1.0.dist-info/top_level.txt +1 -0
sandling/__init__.py
ADDED
sandling/__main__.py
ADDED
sandling/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from sandling.service import (
|
|
8
|
+
BootstrapError,
|
|
9
|
+
SandlingError,
|
|
10
|
+
destroy_workspace,
|
|
11
|
+
get_profile,
|
|
12
|
+
init_profile,
|
|
13
|
+
load_all_records,
|
|
14
|
+
load_config,
|
|
15
|
+
mark_ready,
|
|
16
|
+
post_push,
|
|
17
|
+
prune_metadata,
|
|
18
|
+
record_git_summary,
|
|
19
|
+
start_workspace,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="sandling",
|
|
26
|
+
description="Safe, disposable Git workspaces. Temporary by design, not by neglect.",
|
|
27
|
+
)
|
|
28
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
29
|
+
|
|
30
|
+
profile = commands.add_parser("profile", help="create a configured project profile")
|
|
31
|
+
profile_commands = profile.add_subparsers(dest="profile_command", required=True)
|
|
32
|
+
profile_init = profile_commands.add_parser("init", help="add a minimal project profile")
|
|
33
|
+
profile_init.add_argument("name")
|
|
34
|
+
profile_init.add_argument("--repository-url", required=True)
|
|
35
|
+
profile_init.add_argument("--base-branch", default="main")
|
|
36
|
+
|
|
37
|
+
start = commands.add_parser("start", help="clone, select a branch, and bootstrap a workspace")
|
|
38
|
+
start.add_argument("profile", help="configured profile name")
|
|
39
|
+
start.add_argument("branch", nargs="?", help="existing remote branch to check out unchanged")
|
|
40
|
+
start.add_argument(
|
|
41
|
+
"--new-branch", metavar="BRANCH", help="create BRANCH from the configured base branch"
|
|
42
|
+
)
|
|
43
|
+
start.add_argument(
|
|
44
|
+
"--no-editor", action="store_true", help="do not launch the profile editor command"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
listed = commands.add_parser("list", help="list known workspaces and their safety state")
|
|
48
|
+
listed.add_argument("profile", nargs="?", help="show only one profile")
|
|
49
|
+
|
|
50
|
+
flag = commands.add_parser("flag", help="mark a workspace ready for later destruction")
|
|
51
|
+
flag.add_argument("name", nargs="?", help="workspace name; defaults to the current workspace")
|
|
52
|
+
|
|
53
|
+
destroy = commands.add_parser("destroy", help="delete one safe, explicitly confirmed workspace")
|
|
54
|
+
destroy.add_argument("name", help="workspace name from 'sandling list'")
|
|
55
|
+
destroy.add_argument(
|
|
56
|
+
"--force", action="store_true", help="bypass Git-state safeguards; still types name"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
commands.add_parser(
|
|
60
|
+
"prune-metadata", help="remove destroyed-workspace records older than seven days"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
hook = commands.add_parser("_post-push", help=argparse.SUPPRESS)
|
|
64
|
+
hook.add_argument("--workspace", required=True)
|
|
65
|
+
hook.add_argument("--remote", required=True)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def prompt_after_push() -> bool:
|
|
70
|
+
message = "\nSandling: push accepted. Mark this workspace ready for destruction? [y/N] "
|
|
71
|
+
try:
|
|
72
|
+
with Path("/dev/tty").open("r+", encoding="utf-8") as tty:
|
|
73
|
+
tty.write(message)
|
|
74
|
+
tty.flush()
|
|
75
|
+
return tty.readline().strip().lower() in {"y", "yes"}
|
|
76
|
+
except OSError:
|
|
77
|
+
print(
|
|
78
|
+
"Sandling: push accepted. Run 'sandling flag' when the workspace is expendable.",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def print_records(profile_name: str | None) -> None:
|
|
85
|
+
config = load_config()
|
|
86
|
+
records = [
|
|
87
|
+
record
|
|
88
|
+
for record in load_all_records(config)
|
|
89
|
+
if not profile_name or record.profile == profile_name
|
|
90
|
+
]
|
|
91
|
+
if not records:
|
|
92
|
+
print("No Sandling workspaces recorded. An empty beach is not a crisis.")
|
|
93
|
+
return
|
|
94
|
+
print(f"{'NAME':<42} {'PROFILE':<18} {'STATE':<12} {'BRANCH':<30} PATH")
|
|
95
|
+
for record in records:
|
|
96
|
+
state, _ = record_git_summary(record)
|
|
97
|
+
ready = " ready" if record.ready_for_destruction_at else ""
|
|
98
|
+
row = f"{record.name:<42} {record.profile:<18} {(state + ready):<12}"
|
|
99
|
+
print(f"{row} {record.branch:<30} {record.path}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def confirm_destruction(name: str) -> bool:
|
|
103
|
+
return input(f"Type '{name}' to destroy that workspace directory: ").strip() == name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def handle_start(args: argparse.Namespace) -> None:
|
|
107
|
+
if bool(args.branch) == bool(args.new_branch):
|
|
108
|
+
raise SandlingError(
|
|
109
|
+
"Use exactly one: 'sandling start PROFILE BRANCH' or '--new-branch BRANCH'."
|
|
110
|
+
)
|
|
111
|
+
config = load_config()
|
|
112
|
+
profile = get_profile(config, args.profile)
|
|
113
|
+
branch = args.new_branch or args.branch
|
|
114
|
+
record = start_workspace(
|
|
115
|
+
config,
|
|
116
|
+
profile,
|
|
117
|
+
branch=branch,
|
|
118
|
+
create_new_branch=bool(args.new_branch),
|
|
119
|
+
open_editor=not args.no_editor,
|
|
120
|
+
)
|
|
121
|
+
source = (
|
|
122
|
+
f"{profile.push_remote}/{profile.base_branch}"
|
|
123
|
+
if args.new_branch
|
|
124
|
+
else f"{profile.push_remote}/{branch} (unchanged)"
|
|
125
|
+
)
|
|
126
|
+
print(f"Sandling workspace ready: {record.path}\nBranch: {branch}\nSource: {source}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def handle_post_push(args: argparse.Namespace) -> None:
|
|
130
|
+
config = load_config()
|
|
131
|
+
record = post_push(
|
|
132
|
+
config,
|
|
133
|
+
workspace=Path(args.workspace).resolve(),
|
|
134
|
+
remote=args.remote,
|
|
135
|
+
pushed_refs=sys.stdin.read(),
|
|
136
|
+
)
|
|
137
|
+
if record and prompt_after_push():
|
|
138
|
+
mark_ready(config, record.name)
|
|
139
|
+
print(
|
|
140
|
+
f"Sandling: {record.name} is marked ready for destruction. "
|
|
141
|
+
"It will not wander off by itself."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv: list[str] | None = None) -> None:
|
|
146
|
+
args = build_parser().parse_args(argv)
|
|
147
|
+
try:
|
|
148
|
+
if args.command == "profile":
|
|
149
|
+
path = init_profile(args.name, args.repository_url, args.base_branch)
|
|
150
|
+
print(f"Sandling profile {args.name!r} added to {path}.")
|
|
151
|
+
else:
|
|
152
|
+
config = load_config()
|
|
153
|
+
if args.command == "start":
|
|
154
|
+
prune_metadata(config)
|
|
155
|
+
handle_start(args)
|
|
156
|
+
elif args.command == "list":
|
|
157
|
+
prune_metadata(config)
|
|
158
|
+
print_records(args.profile)
|
|
159
|
+
elif args.command == "flag":
|
|
160
|
+
prune_metadata(config)
|
|
161
|
+
record = mark_ready(config, args.name)
|
|
162
|
+
print(
|
|
163
|
+
f"Sandling: {record.name} is marked ready for destruction. "
|
|
164
|
+
"Nothing has been deleted."
|
|
165
|
+
)
|
|
166
|
+
elif args.command == "destroy":
|
|
167
|
+
prune_metadata(config)
|
|
168
|
+
if not confirm_destruction(args.name):
|
|
169
|
+
raise SandlingError("Destruction cancelled: workspace name did not match.")
|
|
170
|
+
record = destroy_workspace(config, args.name, force=args.force)
|
|
171
|
+
print(f"Sandling: destroyed {record.path}. Metadata is retained for seven days.")
|
|
172
|
+
elif args.command == "prune-metadata":
|
|
173
|
+
removed = prune_metadata(config)
|
|
174
|
+
print(
|
|
175
|
+
f"Sandling: removed {removed} expired destroyed-workspace metadata record(s)."
|
|
176
|
+
)
|
|
177
|
+
elif args.command == "_post-push":
|
|
178
|
+
prune_metadata(config)
|
|
179
|
+
handle_post_push(args)
|
|
180
|
+
except BootstrapError as error:
|
|
181
|
+
print(f"Sandling workspace bootstrap failed: {error.workspace}")
|
|
182
|
+
print(f"Sandling: {error}", file=sys.stderr)
|
|
183
|
+
raise SystemExit(2) from error
|
|
184
|
+
except SandlingError as error:
|
|
185
|
+
print(f"Sandling: {error}", file=sys.stderr)
|
|
186
|
+
raise SystemExit(2) from error
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
sandling/service.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG_DIR = Path("~/.config/sandling")
|
|
16
|
+
DEFAULT_STATE_ROOT = Path("~/.local/state/sandling")
|
|
17
|
+
DEFAULT_SANDBOX_ROOT = Path("~/.local/share/sandling/sandboxes")
|
|
18
|
+
METADATA_NAME = ".sandling.json"
|
|
19
|
+
PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
|
20
|
+
PLACEHOLDER = re.compile(r"\{([a-z_]+)\}")
|
|
21
|
+
ALLOWED_PLACEHOLDERS = {"sandbox", "profile", "branch"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SandlingError(RuntimeError):
|
|
25
|
+
"""An expected, actionable Sandling failure."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BootstrapError(SandlingError):
|
|
29
|
+
def __init__(self, workspace: Path, error: SandlingError) -> None:
|
|
30
|
+
self.workspace = workspace
|
|
31
|
+
super().__init__(f"Bootstrap failed for retained workspace {workspace}.\n{error}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Profile:
|
|
36
|
+
name: str
|
|
37
|
+
repository_url: str
|
|
38
|
+
base_branch: str
|
|
39
|
+
sandbox_root: Path
|
|
40
|
+
push_remote: str
|
|
41
|
+
bootstrap: tuple[tuple[str, ...], ...]
|
|
42
|
+
editor_command: tuple[str, ...] | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Config:
|
|
47
|
+
state_root: Path
|
|
48
|
+
profiles: dict[str, Profile]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class WorkspaceRecord:
|
|
53
|
+
name: str
|
|
54
|
+
path: str
|
|
55
|
+
profile: str
|
|
56
|
+
branch: str
|
|
57
|
+
sandbox_root: str
|
|
58
|
+
push_remote: str
|
|
59
|
+
created_at: str
|
|
60
|
+
status: str
|
|
61
|
+
ready_for_destruction_at: str | None = None
|
|
62
|
+
destroyed_at: str | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def workspace_path(self) -> Path:
|
|
66
|
+
return Path(self.path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def utc_now() -> str:
|
|
70
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def expand_path(value: str | Path) -> Path:
|
|
74
|
+
return Path(value).expanduser().resolve()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def config_path() -> Path:
|
|
78
|
+
configured = os.environ.get("SANDLING_CONFIG")
|
|
79
|
+
if configured:
|
|
80
|
+
return expand_path(configured)
|
|
81
|
+
config_home = Path(os.environ.get("XDG_CONFIG_HOME", DEFAULT_CONFIG_DIR.parent))
|
|
82
|
+
return expand_path(config_home / "sandling" / "config.toml")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _as_string_list(value: Any, field: str) -> tuple[str, ...]:
|
|
86
|
+
if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
|
|
87
|
+
raise SandlingError(f"{field} must be a non-empty array of strings.")
|
|
88
|
+
return tuple(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bootstrap_steps(value: Any, profile_name: str) -> tuple[tuple[str, ...], ...]:
|
|
92
|
+
if value is None:
|
|
93
|
+
return ()
|
|
94
|
+
if not isinstance(value, list):
|
|
95
|
+
raise SandlingError(f"profiles.{profile_name}.bootstrap must be an array of argv arrays.")
|
|
96
|
+
return tuple(
|
|
97
|
+
_as_string_list(step, f"profiles.{profile_name}.bootstrap[{index}]")
|
|
98
|
+
for index, step in enumerate(value)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_config() -> Config:
|
|
103
|
+
path = config_path()
|
|
104
|
+
if not path.exists():
|
|
105
|
+
raise SandlingError(
|
|
106
|
+
f"No Sandling configuration at {path}. "
|
|
107
|
+
"Run 'sandling profile init NAME --repository-url URL'."
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
with path.open("rb") as handle:
|
|
111
|
+
raw = tomllib.load(handle)
|
|
112
|
+
except tomllib.TOMLDecodeError as error:
|
|
113
|
+
raise SandlingError(f"Invalid Sandling configuration at {path}: {error}") from error
|
|
114
|
+
|
|
115
|
+
settings = raw.get("settings", {})
|
|
116
|
+
if not isinstance(settings, dict):
|
|
117
|
+
raise SandlingError("settings must be a TOML table.")
|
|
118
|
+
xdg_state_root = Path(os.environ.get("XDG_STATE_HOME", DEFAULT_STATE_ROOT.parent)) / "sandling"
|
|
119
|
+
state_root = expand_path(settings.get("state_root", xdg_state_root))
|
|
120
|
+
default_sandbox_root = expand_path(settings.get("sandbox_root", DEFAULT_SANDBOX_ROOT))
|
|
121
|
+
raw_profiles = raw.get("profiles", {})
|
|
122
|
+
if not isinstance(raw_profiles, dict):
|
|
123
|
+
raise SandlingError("profiles must be a TOML table.")
|
|
124
|
+
|
|
125
|
+
profiles: dict[str, Profile] = {}
|
|
126
|
+
for name, value in raw_profiles.items():
|
|
127
|
+
if not isinstance(name, str) or not PROFILE_NAME.fullmatch(name):
|
|
128
|
+
raise SandlingError(
|
|
129
|
+
"Profile names must start with a lowercase letter and contain letters, "
|
|
130
|
+
"digits, _ or -."
|
|
131
|
+
)
|
|
132
|
+
if not isinstance(value, dict):
|
|
133
|
+
raise SandlingError(f"profiles.{name} must be a TOML table.")
|
|
134
|
+
repository_url = value.get("repository_url")
|
|
135
|
+
if not isinstance(repository_url, str) or not repository_url.strip():
|
|
136
|
+
raise SandlingError(f"profiles.{name}.repository_url must be a non-empty string.")
|
|
137
|
+
base_branch = value.get("base_branch", "main")
|
|
138
|
+
push_remote = value.get("push_remote", "origin")
|
|
139
|
+
if not isinstance(base_branch, str) or not base_branch:
|
|
140
|
+
raise SandlingError(f"profiles.{name}.base_branch must be a non-empty string.")
|
|
141
|
+
if not isinstance(push_remote, str) or not push_remote:
|
|
142
|
+
raise SandlingError(f"profiles.{name}.push_remote must be a non-empty string.")
|
|
143
|
+
editor_value = value.get("editor_command")
|
|
144
|
+
profiles[name] = Profile(
|
|
145
|
+
name=name,
|
|
146
|
+
repository_url=repository_url,
|
|
147
|
+
base_branch=base_branch,
|
|
148
|
+
sandbox_root=expand_path(value.get("sandbox_root", default_sandbox_root / name)),
|
|
149
|
+
push_remote=push_remote,
|
|
150
|
+
bootstrap=_bootstrap_steps(value.get("bootstrap"), name),
|
|
151
|
+
editor_command=(
|
|
152
|
+
_as_string_list(editor_value, f"profiles.{name}.editor_command")
|
|
153
|
+
if editor_value is not None
|
|
154
|
+
else None
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
return Config(state_root=state_root, profiles=profiles)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def init_profile(name: str, repository_url: str, base_branch: str) -> Path:
|
|
161
|
+
if not PROFILE_NAME.fullmatch(name):
|
|
162
|
+
raise SandlingError(
|
|
163
|
+
"Profile names must start with a lowercase letter and contain letters, digits, _ or -."
|
|
164
|
+
)
|
|
165
|
+
if not repository_url.strip():
|
|
166
|
+
raise SandlingError("repository URL must not be empty.")
|
|
167
|
+
if not base_branch.strip():
|
|
168
|
+
raise SandlingError("base branch must not be empty.")
|
|
169
|
+
path = config_path()
|
|
170
|
+
if path.exists():
|
|
171
|
+
config = load_config()
|
|
172
|
+
if name in config.profiles:
|
|
173
|
+
raise SandlingError(f"Profile {name!r} already exists in {path}.")
|
|
174
|
+
else:
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
path.write_text(
|
|
177
|
+
"# Sandling profiles. Bootstrap commands are argv arrays; no shell is involved.\n",
|
|
178
|
+
encoding="utf-8",
|
|
179
|
+
)
|
|
180
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
181
|
+
handle.write(
|
|
182
|
+
f"\n[profiles.{name}]\n"
|
|
183
|
+
f"repository_url = {json.dumps(repository_url)}\n"
|
|
184
|
+
f"base_branch = {json.dumps(base_branch)}\n"
|
|
185
|
+
)
|
|
186
|
+
return path
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_profile(config: Config, name: str) -> Profile:
|
|
190
|
+
try:
|
|
191
|
+
return config.profiles[name]
|
|
192
|
+
except KeyError as error:
|
|
193
|
+
known = ", ".join(sorted(config.profiles)) or "none"
|
|
194
|
+
raise SandlingError(f"Unknown profile {name!r}. Configured profiles: {known}.") from error
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def state_path(config: Config, name: str) -> Path:
|
|
198
|
+
return config.state_root / "workspaces" / f"{name}.json"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def save_record(config: Config, record: WorkspaceRecord) -> None:
|
|
202
|
+
path = state_path(config, record.name)
|
|
203
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
path.write_text(json.dumps(asdict(record), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _record_from_path(path: Path) -> WorkspaceRecord:
|
|
208
|
+
try:
|
|
209
|
+
return WorkspaceRecord(**json.loads(path.read_text(encoding="utf-8")))
|
|
210
|
+
except (OSError, TypeError, json.JSONDecodeError) as error:
|
|
211
|
+
raise SandlingError(f"Invalid Sandling metadata at {path}: {error}") from error
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_record(config: Config, name: str) -> WorkspaceRecord:
|
|
215
|
+
path = state_path(config, name)
|
|
216
|
+
if not path.exists():
|
|
217
|
+
raise SandlingError(
|
|
218
|
+
f"No Sandling workspace named {name!r}. Run 'sandling list' to see known workspaces."
|
|
219
|
+
)
|
|
220
|
+
return _record_from_path(path)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_all_records(config: Config) -> list[WorkspaceRecord]:
|
|
224
|
+
records_dir = config.state_root / "workspaces"
|
|
225
|
+
if not records_dir.exists():
|
|
226
|
+
return []
|
|
227
|
+
return [_record_from_path(path) for path in sorted(records_dir.glob("*.json"))]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run(
|
|
231
|
+
command: list[str], *, cwd: Path | None = None, capture: bool = False
|
|
232
|
+
) -> subprocess.CompletedProcess[str]:
|
|
233
|
+
try:
|
|
234
|
+
return subprocess.run(command, cwd=cwd, check=True, text=True, capture_output=capture)
|
|
235
|
+
except FileNotFoundError as error:
|
|
236
|
+
raise SandlingError(f"Required command not found: {command[0]}") from error
|
|
237
|
+
except subprocess.CalledProcessError as error:
|
|
238
|
+
detail = (error.stderr or error.stdout or "command failed").strip()
|
|
239
|
+
raise SandlingError(f"Command failed: {' '.join(command)}\n{detail}") from error
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def branch_slug(value: str) -> str:
|
|
243
|
+
return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def workspace_name(branch: str) -> str:
|
|
247
|
+
slug = branch_slug(branch)
|
|
248
|
+
if not slug:
|
|
249
|
+
raise SandlingError("Branch name must contain at least one letter or number.")
|
|
250
|
+
return f"{slug}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def metadata_path(workspace: Path) -> Path:
|
|
254
|
+
return workspace / METADATA_NAME
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def write_workspace_metadata(workspace: Path, record: WorkspaceRecord) -> None:
|
|
258
|
+
metadata_path(workspace).write_text(
|
|
259
|
+
json.dumps(asdict(record), indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def install_local_exclude(workspace: Path) -> None:
|
|
264
|
+
exclude = workspace / ".git" / "info" / "exclude"
|
|
265
|
+
current = exclude.read_text(encoding="utf-8") if exclude.exists() else ""
|
|
266
|
+
if METADATA_NAME not in current.splitlines():
|
|
267
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
with exclude.open("a", encoding="utf-8") as handle:
|
|
269
|
+
if current and not current.endswith("\n"):
|
|
270
|
+
handle.write("\n")
|
|
271
|
+
handle.write(f"{METADATA_NAME}\n")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def install_post_push_hook(workspace: Path) -> None:
|
|
275
|
+
hook = workspace / ".git" / "hooks" / "post-push"
|
|
276
|
+
python = str(Path(sys.executable).resolve())
|
|
277
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
hook.write_text(
|
|
279
|
+
"#!/bin/sh\n"
|
|
280
|
+
"# Installed by Sandling. It reminds; it never deletes.\n"
|
|
281
|
+
'workspace="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0\n'
|
|
282
|
+
f'exec "{python}" -m sandling _post-push --workspace "$workspace" --remote "$1"\n',
|
|
283
|
+
encoding="utf-8",
|
|
284
|
+
)
|
|
285
|
+
hook.chmod(0o755)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def expand_command(
|
|
289
|
+
command: tuple[str, ...], *, workspace: Path, profile: Profile, branch: str
|
|
290
|
+
) -> list[str]:
|
|
291
|
+
variables = {"sandbox": str(workspace), "profile": profile.name, "branch": branch}
|
|
292
|
+
expanded: list[str] = []
|
|
293
|
+
for argument in command:
|
|
294
|
+
unknown = set(PLACEHOLDER.findall(argument)) - ALLOWED_PLACEHOLDERS
|
|
295
|
+
if unknown:
|
|
296
|
+
raise SandlingError(f"Unsupported placeholder(s): {', '.join(sorted(unknown))}.")
|
|
297
|
+
for key, value in variables.items():
|
|
298
|
+
argument = argument.replace("{" + key + "}", value)
|
|
299
|
+
expanded.append(argument)
|
|
300
|
+
return expanded
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def start_workspace(
|
|
304
|
+
config: Config,
|
|
305
|
+
profile: Profile,
|
|
306
|
+
*,
|
|
307
|
+
branch: str,
|
|
308
|
+
create_new_branch: bool,
|
|
309
|
+
open_editor: bool = True,
|
|
310
|
+
) -> WorkspaceRecord:
|
|
311
|
+
name = workspace_name(branch)
|
|
312
|
+
workspace = profile.sandbox_root / name
|
|
313
|
+
if workspace.exists():
|
|
314
|
+
raise SandlingError(f"Workspace path already exists: {workspace}")
|
|
315
|
+
profile.sandbox_root.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
record = WorkspaceRecord(
|
|
317
|
+
name=name,
|
|
318
|
+
path=str(workspace),
|
|
319
|
+
profile=profile.name,
|
|
320
|
+
branch=branch,
|
|
321
|
+
sandbox_root=str(profile.sandbox_root),
|
|
322
|
+
push_remote=profile.push_remote,
|
|
323
|
+
created_at=utc_now(),
|
|
324
|
+
status="creating",
|
|
325
|
+
)
|
|
326
|
+
save_record(config, record)
|
|
327
|
+
try:
|
|
328
|
+
run(
|
|
329
|
+
[
|
|
330
|
+
"git",
|
|
331
|
+
"clone",
|
|
332
|
+
"--origin",
|
|
333
|
+
profile.push_remote,
|
|
334
|
+
profile.repository_url,
|
|
335
|
+
str(workspace),
|
|
336
|
+
]
|
|
337
|
+
)
|
|
338
|
+
run(["git", "fetch", profile.push_remote, "--prune"], cwd=workspace)
|
|
339
|
+
if create_new_branch:
|
|
340
|
+
run(
|
|
341
|
+
[
|
|
342
|
+
"git",
|
|
343
|
+
"switch",
|
|
344
|
+
"--create",
|
|
345
|
+
branch,
|
|
346
|
+
f"{profile.push_remote}/{profile.base_branch}",
|
|
347
|
+
],
|
|
348
|
+
cwd=workspace,
|
|
349
|
+
)
|
|
350
|
+
elif branch == profile.base_branch:
|
|
351
|
+
run(["git", "switch", branch], cwd=workspace)
|
|
352
|
+
else:
|
|
353
|
+
run(
|
|
354
|
+
["git", "switch", "--track", "--create", branch, f"{profile.push_remote}/{branch}"],
|
|
355
|
+
cwd=workspace,
|
|
356
|
+
)
|
|
357
|
+
install_local_exclude(workspace)
|
|
358
|
+
install_post_push_hook(workspace)
|
|
359
|
+
write_workspace_metadata(workspace, record)
|
|
360
|
+
for command in profile.bootstrap:
|
|
361
|
+
try:
|
|
362
|
+
run(
|
|
363
|
+
expand_command(command, workspace=workspace, profile=profile, branch=branch),
|
|
364
|
+
cwd=workspace,
|
|
365
|
+
)
|
|
366
|
+
except SandlingError as error:
|
|
367
|
+
raise BootstrapError(workspace, error) from error
|
|
368
|
+
except SandlingError:
|
|
369
|
+
record.status = "bootstrap_failed"
|
|
370
|
+
save_record(config, record)
|
|
371
|
+
if workspace.exists():
|
|
372
|
+
write_workspace_metadata(workspace, record)
|
|
373
|
+
raise
|
|
374
|
+
record.status = "active"
|
|
375
|
+
save_record(config, record)
|
|
376
|
+
write_workspace_metadata(workspace, record)
|
|
377
|
+
if open_editor and profile.editor_command:
|
|
378
|
+
command = expand_command(
|
|
379
|
+
profile.editor_command, workspace=workspace, profile=profile, branch=branch
|
|
380
|
+
)
|
|
381
|
+
try:
|
|
382
|
+
subprocess.Popen(command, cwd=workspace, start_new_session=True)
|
|
383
|
+
except FileNotFoundError as error:
|
|
384
|
+
raise SandlingError(
|
|
385
|
+
f"Workspace is ready, but editor command was not found: {command[0]}"
|
|
386
|
+
) from error
|
|
387
|
+
return record
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def find_record_for_current_directory(config: Config) -> WorkspaceRecord:
|
|
391
|
+
current = Path.cwd().resolve()
|
|
392
|
+
for candidate in [current, *current.parents]:
|
|
393
|
+
metadata = metadata_path(candidate)
|
|
394
|
+
if metadata.exists():
|
|
395
|
+
return load_record(config, _record_from_path(metadata).name)
|
|
396
|
+
raise SandlingError(
|
|
397
|
+
"Current directory is not inside a Sandling workspace. Pass a workspace name explicitly."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def mark_ready(config: Config, name: str | None = None) -> WorkspaceRecord:
|
|
402
|
+
record = load_record(config, name) if name else find_record_for_current_directory(config)
|
|
403
|
+
if record.status == "destroyed":
|
|
404
|
+
raise SandlingError(f"Workspace {record.name!r} was already destroyed.")
|
|
405
|
+
record.ready_for_destruction_at = utc_now()
|
|
406
|
+
save_record(config, record)
|
|
407
|
+
if record.workspace_path.exists():
|
|
408
|
+
write_workspace_metadata(record.workspace_path, record)
|
|
409
|
+
return record
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def record_git_summary(record: WorkspaceRecord) -> tuple[str, str]:
|
|
413
|
+
workspace = record.workspace_path
|
|
414
|
+
if not workspace.exists():
|
|
415
|
+
return "missing", ""
|
|
416
|
+
if record.status == "destroyed":
|
|
417
|
+
return "destroyed", ""
|
|
418
|
+
try:
|
|
419
|
+
dirty = run(["git", "status", "--porcelain"], cwd=workspace, capture=True).stdout.strip()
|
|
420
|
+
return ("dirty" if dirty else record.status), dirty
|
|
421
|
+
except SandlingError:
|
|
422
|
+
return "invalid", ""
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def require_safe_to_destroy(record: WorkspaceRecord) -> None:
|
|
426
|
+
workspace = record.workspace_path
|
|
427
|
+
if not workspace.exists():
|
|
428
|
+
raise SandlingError(f"Workspace directory no longer exists: {workspace}")
|
|
429
|
+
status = run(
|
|
430
|
+
["git", "status", "--porcelain", "--untracked-files=all"], cwd=workspace, capture=True
|
|
431
|
+
)
|
|
432
|
+
if status.stdout.strip():
|
|
433
|
+
raise SandlingError(
|
|
434
|
+
"Refusing destruction: working tree contains tracked, staged, or untracked changes."
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
ahead = run(
|
|
438
|
+
["git", "rev-list", "--count", "@{upstream}..HEAD"], cwd=workspace, capture=True
|
|
439
|
+
)
|
|
440
|
+
except SandlingError as error:
|
|
441
|
+
raise SandlingError(
|
|
442
|
+
"Refusing destruction: branch has no usable upstream. Push it first or use --force."
|
|
443
|
+
) from error
|
|
444
|
+
if int(ahead.stdout.strip()) > 0:
|
|
445
|
+
raise SandlingError(
|
|
446
|
+
"Refusing destruction: local commits have not been pushed to the upstream branch."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def destroy_workspace(config: Config, name: str, *, force: bool) -> WorkspaceRecord:
|
|
451
|
+
record = load_record(config, name)
|
|
452
|
+
if record.status == "destroyed":
|
|
453
|
+
raise SandlingError(f"Workspace {name!r} was already destroyed on {record.destroyed_at}.")
|
|
454
|
+
if not force:
|
|
455
|
+
require_safe_to_destroy(record)
|
|
456
|
+
workspace = record.workspace_path.resolve()
|
|
457
|
+
root = expand_path(record.sandbox_root)
|
|
458
|
+
if workspace.parent != root or not metadata_path(workspace).exists():
|
|
459
|
+
raise SandlingError(
|
|
460
|
+
"Refusing destruction: target is not a direct, managed Sandling workspace."
|
|
461
|
+
)
|
|
462
|
+
shutil.rmtree(workspace)
|
|
463
|
+
record.status = "destroyed"
|
|
464
|
+
record.destroyed_at = utc_now()
|
|
465
|
+
save_record(config, record)
|
|
466
|
+
return record
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def prune_metadata(config: Config, *, retention_days: int = 7) -> int:
|
|
470
|
+
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
|
|
471
|
+
removed = 0
|
|
472
|
+
for record in load_all_records(config):
|
|
473
|
+
if (
|
|
474
|
+
record.status == "destroyed"
|
|
475
|
+
and record.destroyed_at
|
|
476
|
+
and datetime.fromisoformat(record.destroyed_at) <= cutoff
|
|
477
|
+
):
|
|
478
|
+
state_path(config, record.name).unlink(missing_ok=True)
|
|
479
|
+
removed += 1
|
|
480
|
+
return removed
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def post_push(
|
|
484
|
+
config: Config, *, workspace: Path, remote: str, pushed_refs: str
|
|
485
|
+
) -> WorkspaceRecord | None:
|
|
486
|
+
if not pushed_refs.strip() or not metadata_path(workspace).exists():
|
|
487
|
+
return None
|
|
488
|
+
record = _record_from_path(metadata_path(workspace))
|
|
489
|
+
if remote != record.push_remote:
|
|
490
|
+
return None
|
|
491
|
+
return load_record(config, record.name)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sandling
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Safe, disposable Git workspaces with fewer abandoned clones.
|
|
5
|
+
Author-email: Camilla Buys <camilla.buys@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Operating System :: POSIX
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Sandling
|
|
20
|
+
|
|
21
|
+
> **Small, temporary Git workspaces that know when to leave.** A Sandling is for
|
|
22
|
+
> debugging, building, and pushing—not becoming an archaeological layer in your home directory.
|
|
23
|
+
|
|
24
|
+
Sandling creates an isolated clone for a configured project, selects exactly the branch you ask
|
|
25
|
+
for, runs explicit bootstrap commands, and can remind you when the workspace is ready to retire.
|
|
26
|
+
It never deletes automatically. Sandboxes are disposable; your work is not.
|
|
27
|
+
|
|
28
|
+
## What it does
|
|
29
|
+
|
|
30
|
+
- Manages one or more explicit project profiles. A new install has no repository configured.
|
|
31
|
+
- Creates workspaces from a freshly fetched base branch or an existing remote branch unchanged.
|
|
32
|
+
- Runs profile bootstrap steps as argument arrays, never through a shell.
|
|
33
|
+
- Optionally launches your configured editor.
|
|
34
|
+
- Installs a local post-push reminder. It can mark a workspace ready; it cannot delete one.
|
|
35
|
+
- Refuses ordinary destruction when a workspace is dirty, contains untracked files, has unpushed
|
|
36
|
+
commits, or lacks a usable upstream.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
Sandling supports Linux and macOS with Python 3.11 or later.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install sandling
|
|
44
|
+
sandling --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Upgrade a published installation with `pipx upgrade sandling`. Sandling does not try to update
|
|
48
|
+
itself from a random checkout; that is how tools acquire hobbies.
|
|
49
|
+
|
|
50
|
+
For local development:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m pip install -e '.[dev]'
|
|
54
|
+
python -m pytest
|
|
55
|
+
python -m ruff check src tests
|
|
56
|
+
python -m ruff format --check src tests
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Configure a project
|
|
60
|
+
|
|
61
|
+
Create a profile for any Git repository:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
sandling profile init demo --repository-url https://example.test/demo.git --base-branch main
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This creates `~/.config/sandling/config.toml` (or `$XDG_CONFIG_HOME/sandling/config.toml`). Add
|
|
68
|
+
optional bootstrap and editor commands as shell-free argument arrays:
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[profiles.demo]
|
|
72
|
+
repository_url = "https://example.test/demo.git"
|
|
73
|
+
base_branch = "main"
|
|
74
|
+
editor_command = ["code", "."]
|
|
75
|
+
bootstrap = [
|
|
76
|
+
["python3", "-m", "venv", ".venv"],
|
|
77
|
+
["{sandbox}/.venv/bin/python", "-m", "pip", "install", "-r", "requirements.txt"],
|
|
78
|
+
]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Recognised placeholders are `{sandbox}`, `{profile}`, and `{branch}`. The profile is your
|
|
82
|
+
configuration; review it with the same calm suspicion you reserve for shell aliases copied from
|
|
83
|
+
the internet.
|
|
84
|
+
|
|
85
|
+
## Start work
|
|
86
|
+
|
|
87
|
+
Create a new branch from the profile's freshly fetched base branch:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sandling start demo --new-branch fix/parse-dates
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or check out an existing remote branch without merging or rebasing it:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
sandling start demo fix/parse-dates
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
List known workspaces and mark one after its push is accepted:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
sandling list
|
|
103
|
+
sandling flag fix_parse_dates-20261110-090000
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
In an interactive Bash shell, source `scripts/sandling-shell.bash` if you want successful
|
|
107
|
+
`sandling start` calls to enter the newly created workspace. A Python process cannot move its
|
|
108
|
+
parent shell; physics is rude but consistent.
|
|
109
|
+
|
|
110
|
+
## Safe cleanup
|
|
111
|
+
|
|
112
|
+
Destroying requires an exact-name confirmation:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
sandling destroy fix_parse_dates-20261110-090000
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Normal destruction refuses dirty, untracked, unpushed, and upstream-less workspaces. `--force`
|
|
119
|
+
bypasses those Git-state checks but still requires the exact workspace name. Use it slowly, after
|
|
120
|
+
checking the path, and ideally after coffee.
|
|
121
|
+
|
|
122
|
+
Destroyed-workspace metadata remains for seven days. `sandling prune-metadata` removes only those
|
|
123
|
+
expired records; it never removes a workspace directory. There is deliberately no clean-all and no
|
|
124
|
+
remote-branch deletion command.
|
|
125
|
+
|
|
126
|
+
## Security and scope
|
|
127
|
+
|
|
128
|
+
Sandling runs only the commands you place in a profile and never invokes a shell for bootstrap
|
|
129
|
+
steps. It does not manage credentials, install system dependencies, configure project services, or
|
|
130
|
+
make remote Git changes other than normal user-initiated pushes from a workspace.
|
|
131
|
+
|
|
132
|
+
Report vulnerabilities through the process in [SECURITY.md](SECURITY.md). Contributions are
|
|
133
|
+
welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
Created by Camilla Buys. Build fast. Push clean. Leave no clones behind.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
sandling/__init__.py,sha256=l9fy3KcQGbQYLe0Z4PJ7qUQ-heWpp08LOWYmhq1mF8Y,91
|
|
2
|
+
sandling/__main__.py,sha256=YGs7mu3aWup4dHk27xBlxdsODhyxkLzjQ2KZ35QEM-I,38
|
|
3
|
+
sandling/cli.py,sha256=IlqQcHgGxaH4tYDo7nYizlP6tWpgEIz_LZh1KRJQnXg,7191
|
|
4
|
+
sandling/service.py,sha256=DVw6K9zYpzmx1TiMB3P7NsLX_dcK0IW3ZJqfGxpmI8Y,17900
|
|
5
|
+
sandling-0.1.0.dist-info/licenses/LICENSE,sha256=6ABkA3YA59ExTfToDTR3k2qhjpvDJExAF_XYi58efoo,1069
|
|
6
|
+
sandling-0.1.0.dist-info/METADATA,sha256=Sl8R-zGvnJrndD7a7WRxLcubttJnKbJCYNEN4wGNkMg,4618
|
|
7
|
+
sandling-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
sandling-0.1.0.dist-info/entry_points.txt,sha256=0CoQ9QzoH7eT2hCG5rw9IfTNGXi_kOznPsoyGp7eAhQ,47
|
|
9
|
+
sandling-0.1.0.dist-info/top_level.txt,sha256=iWifh5kHdLZ96lfVKX6zR1zOBAPKQrMAh2KqVe7R5mw,9
|
|
10
|
+
sandling-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Camilla Buys
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sandling
|