sandling 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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,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,119 @@
1
+ # Sandling
2
+
3
+ > **Small, temporary Git workspaces that know when to leave.** A Sandling is for
4
+ > debugging, building, and pushing—not becoming an archaeological layer in your home directory.
5
+
6
+ Sandling creates an isolated clone for a configured project, selects exactly the branch you ask
7
+ for, runs explicit bootstrap commands, and can remind you when the workspace is ready to retire.
8
+ It never deletes automatically. Sandboxes are disposable; your work is not.
9
+
10
+ ## What it does
11
+
12
+ - Manages one or more explicit project profiles. A new install has no repository configured.
13
+ - Creates workspaces from a freshly fetched base branch or an existing remote branch unchanged.
14
+ - Runs profile bootstrap steps as argument arrays, never through a shell.
15
+ - Optionally launches your configured editor.
16
+ - Installs a local post-push reminder. It can mark a workspace ready; it cannot delete one.
17
+ - Refuses ordinary destruction when a workspace is dirty, contains untracked files, has unpushed
18
+ commits, or lacks a usable upstream.
19
+
20
+ ## Install
21
+
22
+ Sandling supports Linux and macOS with Python 3.11 or later.
23
+
24
+ ```bash
25
+ pipx install sandling
26
+ sandling --help
27
+ ```
28
+
29
+ Upgrade a published installation with `pipx upgrade sandling`. Sandling does not try to update
30
+ itself from a random checkout; that is how tools acquire hobbies.
31
+
32
+ For local development:
33
+
34
+ ```bash
35
+ python -m pip install -e '.[dev]'
36
+ python -m pytest
37
+ python -m ruff check src tests
38
+ python -m ruff format --check src tests
39
+ ```
40
+
41
+ ## Configure a project
42
+
43
+ Create a profile for any Git repository:
44
+
45
+ ```bash
46
+ sandling profile init demo --repository-url https://example.test/demo.git --base-branch main
47
+ ```
48
+
49
+ This creates `~/.config/sandling/config.toml` (or `$XDG_CONFIG_HOME/sandling/config.toml`). Add
50
+ optional bootstrap and editor commands as shell-free argument arrays:
51
+
52
+ ```toml
53
+ [profiles.demo]
54
+ repository_url = "https://example.test/demo.git"
55
+ base_branch = "main"
56
+ editor_command = ["code", "."]
57
+ bootstrap = [
58
+ ["python3", "-m", "venv", ".venv"],
59
+ ["{sandbox}/.venv/bin/python", "-m", "pip", "install", "-r", "requirements.txt"],
60
+ ]
61
+ ```
62
+
63
+ Recognised placeholders are `{sandbox}`, `{profile}`, and `{branch}`. The profile is your
64
+ configuration; review it with the same calm suspicion you reserve for shell aliases copied from
65
+ the internet.
66
+
67
+ ## Start work
68
+
69
+ Create a new branch from the profile's freshly fetched base branch:
70
+
71
+ ```bash
72
+ sandling start demo --new-branch fix/parse-dates
73
+ ```
74
+
75
+ Or check out an existing remote branch without merging or rebasing it:
76
+
77
+ ```bash
78
+ sandling start demo fix/parse-dates
79
+ ```
80
+
81
+ List known workspaces and mark one after its push is accepted:
82
+
83
+ ```bash
84
+ sandling list
85
+ sandling flag fix_parse_dates-20261110-090000
86
+ ```
87
+
88
+ In an interactive Bash shell, source `scripts/sandling-shell.bash` if you want successful
89
+ `sandling start` calls to enter the newly created workspace. A Python process cannot move its
90
+ parent shell; physics is rude but consistent.
91
+
92
+ ## Safe cleanup
93
+
94
+ Destroying requires an exact-name confirmation:
95
+
96
+ ```bash
97
+ sandling destroy fix_parse_dates-20261110-090000
98
+ ```
99
+
100
+ Normal destruction refuses dirty, untracked, unpushed, and upstream-less workspaces. `--force`
101
+ bypasses those Git-state checks but still requires the exact workspace name. Use it slowly, after
102
+ checking the path, and ideally after coffee.
103
+
104
+ Destroyed-workspace metadata remains for seven days. `sandling prune-metadata` removes only those
105
+ expired records; it never removes a workspace directory. There is deliberately no clean-all and no
106
+ remote-branch deletion command.
107
+
108
+ ## Security and scope
109
+
110
+ Sandling runs only the commands you place in a profile and never invokes a shell for bootstrap
111
+ steps. It does not manage credentials, install system dependencies, configure project services, or
112
+ make remote Git changes other than normal user-initiated pushes from a workspace.
113
+
114
+ Report vulnerabilities through the process in [SECURITY.md](SECURITY.md). Contributions are
115
+ welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
116
+
117
+ ---
118
+
119
+ Created by Camilla Buys. Build fast. Push clean. Leave no clones behind.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sandling"
7
+ version = "0.1.0"
8
+ description = "Safe, disposable Git workspaces with fewer abandoned clones."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Camilla Buys", email = "camilla.buys@gmail.com" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Operating System :: POSIX",
17
+ ]
18
+
19
+ [project.scripts]
20
+ sandling = "sandling.cli:main"
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["build>=1.2", "pytest>=8.0", "ruff>=0.9"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ addopts = "-q"
28
+
29
+ [tool.ruff]
30
+ target-version = "py311"
31
+ line-length = 100
32
+
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Sandling: short-lived Git workspaces that know when to leave."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from sandling.cli import main
2
+
3
+ main()
@@ -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()