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 ADDED
@@ -0,0 +1,3 @@
1
+ """Sandling: short-lived Git workspaces that know when to leave."""
2
+
3
+ __version__ = "0.1.0"
sandling/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sandling.cli import main
2
+
3
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sandling = sandling.cli:main
@@ -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