naissance 2.0.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.
- naissance/__init__.py +3 -0
- naissance/__main__.py +6 -0
- naissance/cli.py +66 -0
- naissance/core.py +261 -0
- naissance/errors.py +94 -0
- naissance/git_ops.py +166 -0
- naissance/main.py +14 -0
- naissance/path_resolver.py +62 -0
- naissance-2.0.0.dist-info/METADATA +193 -0
- naissance-2.0.0.dist-info/RECORD +14 -0
- naissance-2.0.0.dist-info/WHEEL +5 -0
- naissance-2.0.0.dist-info/entry_points.txt +2 -0
- naissance-2.0.0.dist-info/licenses/LICENSE +9 -0
- naissance-2.0.0.dist-info/top_level.txt +1 -0
naissance/__init__.py
ADDED
naissance/__main__.py
ADDED
naissance/cli.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — Argument parsing for naissance.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
naissance <repo_name> # CWD-relative (default)
|
|
6
|
+
naissance <absolute_path> --apath # Absolute path
|
|
7
|
+
naissance . # CWD, folder name = repo name
|
|
8
|
+
naissance <repo_name> --public # Override visibility
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
|
|
13
|
+
from naissance import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
17
|
+
"""
|
|
18
|
+
Parse command-line arguments and return a Namespace.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
argv : list[str] | None
|
|
23
|
+
Explicit argument list (for testing). Defaults to sys.argv[1:].
|
|
24
|
+
"""
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="naissance",
|
|
27
|
+
description="Git Repository Genesis — create local + remote repos in one shot.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"repo_name",
|
|
32
|
+
help=(
|
|
33
|
+
'Name (or path) of the repository to create. '
|
|
34
|
+
'Resolved relative to CWD by default. '
|
|
35
|
+
'Use "." for current directory. '
|
|
36
|
+
'Use --apath for absolute paths.'
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--apath",
|
|
42
|
+
action="store_true",
|
|
43
|
+
default=False,
|
|
44
|
+
help="Treat repo_name as an absolute path.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--name",
|
|
49
|
+
default=None,
|
|
50
|
+
help="Explicit name for the remote repository (overrides directory name).",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--public",
|
|
55
|
+
action="store_true",
|
|
56
|
+
default=False,
|
|
57
|
+
help="Create the remote repository as public (default: private).",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"-v", "--version",
|
|
62
|
+
action="version",
|
|
63
|
+
version=f"naissance {__version__}",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return parser.parse_args(argv)
|
naissance/core.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core.py — Main orchestrator for naissance.
|
|
3
|
+
|
|
4
|
+
Executes the full repo-genesis workflow with ZERO interactive prompts.
|
|
5
|
+
All decisions are driven by defaults.yml.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from naissance import errors
|
|
14
|
+
from naissance import git_ops
|
|
15
|
+
from naissance import path_resolver
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Directory state enum ──────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
class DirState(enum.Enum):
|
|
21
|
+
NOT_EXISTS = "not_exists"
|
|
22
|
+
EMPTY = "empty"
|
|
23
|
+
NON_EMPTY = "non_empty"
|
|
24
|
+
GIT_INITIALIZED = "git_initialized"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _detect_state(path: Path) -> DirState:
|
|
28
|
+
"""Detect the current state of the target directory."""
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return DirState.NOT_EXISTS
|
|
31
|
+
|
|
32
|
+
if not path.is_dir():
|
|
33
|
+
errors.fail(f"Path exists but is not a directory: {path}")
|
|
34
|
+
|
|
35
|
+
# Check for .git/ first — a git repo is always "non-empty" but
|
|
36
|
+
# we treat it as its own state.
|
|
37
|
+
if (path / ".git").is_dir():
|
|
38
|
+
return DirState.GIT_INITIALIZED
|
|
39
|
+
|
|
40
|
+
# Check if directory is truly empty
|
|
41
|
+
children = list(path.iterdir())
|
|
42
|
+
if not children:
|
|
43
|
+
return DirState.EMPTY
|
|
44
|
+
|
|
45
|
+
return DirState.NON_EMPTY
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Defaults loader ──────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _load_defaults() -> dict:
|
|
51
|
+
"""Load defaults.yml from the package directory."""
|
|
52
|
+
defaults_path = Path(__file__).parent / "defaults" / "defaults.yml"
|
|
53
|
+
|
|
54
|
+
if not defaults_path.exists():
|
|
55
|
+
errors.fail(f"defaults.yml not found at: {defaults_path}")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(defaults_path, "r", encoding="utf-8") as f:
|
|
59
|
+
data = yaml.safe_load(f)
|
|
60
|
+
except yaml.YAMLError as exc:
|
|
61
|
+
errors.fail(f"Failed to parse defaults.yml: {exc}")
|
|
62
|
+
|
|
63
|
+
return data or {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Scaffold helpers ─────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def _create_scaffold(path: Path, defaults: dict) -> list[str]:
|
|
69
|
+
"""
|
|
70
|
+
Create README.md and .gitignore based on defaults.
|
|
71
|
+
Returns list of created filenames (for staging).
|
|
72
|
+
"""
|
|
73
|
+
created: list[str] = []
|
|
74
|
+
|
|
75
|
+
if defaults.get("create_readme", True):
|
|
76
|
+
readme = path / "README.md"
|
|
77
|
+
if not readme.exists():
|
|
78
|
+
readme.write_text("", encoding="utf-8")
|
|
79
|
+
errors.success("README.md")
|
|
80
|
+
created.append("README.md")
|
|
81
|
+
else:
|
|
82
|
+
errors.warn("README.md already exists, skipping")
|
|
83
|
+
|
|
84
|
+
if defaults.get("create_gitignore", True):
|
|
85
|
+
gitignore = path / ".gitignore"
|
|
86
|
+
if not gitignore.exists():
|
|
87
|
+
gitignore.write_text("", encoding="utf-8")
|
|
88
|
+
errors.success(".gitignore")
|
|
89
|
+
created.append(".gitignore")
|
|
90
|
+
else:
|
|
91
|
+
errors.warn(".gitignore already exists, skipping")
|
|
92
|
+
|
|
93
|
+
return created
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── State handlers ────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def _handle_not_exists(
|
|
99
|
+
path: Path,
|
|
100
|
+
repo_name: str,
|
|
101
|
+
visibility: str,
|
|
102
|
+
defaults: dict,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Directory doesn't exist → create it, clone remote into it."""
|
|
105
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
errors.success(f"Directory created: {path}")
|
|
107
|
+
|
|
108
|
+
# Create remote and clone into the directory.
|
|
109
|
+
# gh repo create <name> --clone creates ./<name> in the parent dir,
|
|
110
|
+
# so we need the parent, and the folder name must match repo_name.
|
|
111
|
+
git_ops.gh_repo_create_clone(
|
|
112
|
+
repo_name,
|
|
113
|
+
visibility=visibility,
|
|
114
|
+
clone_into=path,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Scaffold files inside the now-cloned repo
|
|
118
|
+
created = _create_scaffold(path, defaults)
|
|
119
|
+
|
|
120
|
+
if created:
|
|
121
|
+
git_ops.git_add(path, *created)
|
|
122
|
+
git_ops.git_commit(path, defaults.get("initial_commit_message", "initial commit"))
|
|
123
|
+
branch = git_ops.git_branch_current(path, defaults.get("default_branch", "main"))
|
|
124
|
+
git_ops.git_push(path, branch)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _handle_empty(
|
|
128
|
+
path: Path,
|
|
129
|
+
repo_name: str,
|
|
130
|
+
visibility: str,
|
|
131
|
+
defaults: dict,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Directory exists but is empty → clone remote into it."""
|
|
134
|
+
# gh repo create --clone will try to create the subdir, but it
|
|
135
|
+
# already exists (empty). We init locally instead, then use --source.
|
|
136
|
+
git_ops.git_init(path)
|
|
137
|
+
|
|
138
|
+
# Create remote from the local source
|
|
139
|
+
created = _create_scaffold(path, defaults)
|
|
140
|
+
if created:
|
|
141
|
+
git_ops.git_add(path, *created)
|
|
142
|
+
git_ops.git_commit(path, defaults.get("initial_commit_message", "initial commit"))
|
|
143
|
+
|
|
144
|
+
git_ops.gh_repo_create_source(
|
|
145
|
+
path,
|
|
146
|
+
repo_name,
|
|
147
|
+
visibility=visibility,
|
|
148
|
+
push=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _handle_non_empty(
|
|
153
|
+
path: Path,
|
|
154
|
+
repo_name: str,
|
|
155
|
+
visibility: str,
|
|
156
|
+
defaults: dict,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Directory has files but no .git → init, scaffold, push."""
|
|
159
|
+
git_ops.git_init(path)
|
|
160
|
+
|
|
161
|
+
_create_scaffold(path, defaults)
|
|
162
|
+
|
|
163
|
+
# Stage everything
|
|
164
|
+
git_ops.git_add(path, ".")
|
|
165
|
+
git_ops.git_commit(path, defaults.get("initial_commit_message", "initial commit"))
|
|
166
|
+
|
|
167
|
+
git_ops.gh_repo_create_source(
|
|
168
|
+
path,
|
|
169
|
+
repo_name,
|
|
170
|
+
visibility=visibility,
|
|
171
|
+
push=True,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _handle_git_initialized(
|
|
176
|
+
path: Path,
|
|
177
|
+
repo_name: str,
|
|
178
|
+
visibility: str,
|
|
179
|
+
defaults: dict,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Directory already has .git → just link remote."""
|
|
182
|
+
errors.info("Git already initialized")
|
|
183
|
+
|
|
184
|
+
if git_ops.git_has_remote(path):
|
|
185
|
+
errors.fail("Repository already has a remote origin configured")
|
|
186
|
+
|
|
187
|
+
git_ops.gh_repo_create_source(
|
|
188
|
+
path,
|
|
189
|
+
repo_name,
|
|
190
|
+
visibility=visibility,
|
|
191
|
+
push=True,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── Handler dispatch ─────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
_HANDLERS = {
|
|
198
|
+
DirState.NOT_EXISTS: _handle_not_exists,
|
|
199
|
+
DirState.EMPTY: _handle_empty,
|
|
200
|
+
DirState.NON_EMPTY: _handle_non_empty,
|
|
201
|
+
DirState.GIT_INITIALIZED: _handle_git_initialized,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── Main entry ────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def run(args) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Execute the full repository genesis workflow.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
args : argparse.Namespace
|
|
214
|
+
Parsed CLI arguments (repo_name, apath, public).
|
|
215
|
+
"""
|
|
216
|
+
errors.header()
|
|
217
|
+
print(" Birthing repository...\n")
|
|
218
|
+
|
|
219
|
+
# ── Load configuration ────────────────────────────────────────────
|
|
220
|
+
defaults = _load_defaults()
|
|
221
|
+
|
|
222
|
+
# ── Preflight checks ──────────────────────────────────────────────
|
|
223
|
+
errors.require_tool("git")
|
|
224
|
+
errors.success("git found")
|
|
225
|
+
|
|
226
|
+
errors.require_tool("gh", friendly="GitHub CLI (gh)")
|
|
227
|
+
errors.require_gh_auth()
|
|
228
|
+
errors.success("gh authenticated")
|
|
229
|
+
|
|
230
|
+
# ── Resolve path ──────────────────────────────────────────────────
|
|
231
|
+
resolved_path, repo_name = path_resolver.resolve(
|
|
232
|
+
args.repo_name,
|
|
233
|
+
apath=args.apath,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# --name overrides the directory-derived repo name
|
|
237
|
+
if args.name:
|
|
238
|
+
repo_name = args.name.strip()
|
|
239
|
+
|
|
240
|
+
errors.success(f"Path resolved: {resolved_path}")
|
|
241
|
+
|
|
242
|
+
# ── Determine visibility ──────────────────────────────────────────
|
|
243
|
+
if args.public:
|
|
244
|
+
visibility = "public"
|
|
245
|
+
else:
|
|
246
|
+
visibility = defaults.get("visibility", "private")
|
|
247
|
+
|
|
248
|
+
# ── Check if remote already exists ────────────────────────────────
|
|
249
|
+
if git_ops.gh_repo_exists(repo_name):
|
|
250
|
+
errors.fail(f"Remote repository already exists: {repo_name}")
|
|
251
|
+
|
|
252
|
+
# ── Detect directory state ────────────────────────────────────────
|
|
253
|
+
state = _detect_state(resolved_path)
|
|
254
|
+
errors.info(f"Directory state: {state.value}")
|
|
255
|
+
|
|
256
|
+
# ── Dispatch to the appropriate handler ───────────────────────────
|
|
257
|
+
handler = _HANDLERS[state]
|
|
258
|
+
handler(resolved_path, repo_name, visibility, defaults)
|
|
259
|
+
|
|
260
|
+
# ── Done ──────────────────────────────────────────────────────────
|
|
261
|
+
errors.banner(repo_name)
|
naissance/errors.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
errors.py — Centralized error handler for naissance.
|
|
3
|
+
|
|
4
|
+
Provides TUI-style output (✓ / ⨯ / ⚠) and prevents raw subprocess
|
|
5
|
+
tracebacks from ever reaching the user's terminal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── ANSI color codes ──────────────────────────────────────────────────
|
|
12
|
+
_GREEN = "\033[92m"
|
|
13
|
+
_RED = "\033[91m"
|
|
14
|
+
_YELLOW = "\033[93m"
|
|
15
|
+
_CYAN = "\033[96m"
|
|
16
|
+
_DIM = "\033[90m"
|
|
17
|
+
_BOLD = "\033[1m"
|
|
18
|
+
_RESET = "\033[0m"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Symbols ───────────────────────────────────────────────────────────
|
|
22
|
+
_CHECK = "✓"
|
|
23
|
+
_CROSS = "⨯"
|
|
24
|
+
_WARN = "⚠"
|
|
25
|
+
_SPARKLE = "✨"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def success(msg: str) -> None:
|
|
29
|
+
"""Print a green success line: ✓ msg"""
|
|
30
|
+
print(f" {_GREEN}{_CHECK}{_RESET} {msg}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fail(msg: str, *, exit_code: int = 1) -> None:
|
|
34
|
+
"""Print a red failure line: ⨯ msg — then exit."""
|
|
35
|
+
print(f" {_RED}{_CROSS} {msg}{_RESET}", file=sys.stderr)
|
|
36
|
+
sys.exit(exit_code)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def warn(msg: str) -> None:
|
|
40
|
+
"""Print a yellow warning line: ⚠ msg (non-fatal)."""
|
|
41
|
+
print(f" {_YELLOW}{_WARN} {msg}{_RESET}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def info(msg: str) -> None:
|
|
45
|
+
"""Print a dim informational line."""
|
|
46
|
+
print(f" {_DIM}{msg}{_RESET}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def banner(repo_name: str) -> None:
|
|
50
|
+
"""Print the final celebration line."""
|
|
51
|
+
print(f"\n {_SPARKLE} {_CYAN}{_BOLD}{repo_name}{_RESET} is alive.\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def header() -> None:
|
|
55
|
+
"""Print the naissance header."""
|
|
56
|
+
print(f"""
|
|
57
|
+
{_DIM}┼─────────────────────────────────────────────────────────────┼
|
|
58
|
+
| _ __ _
|
|
59
|
+
| / | / /___ _(_)_____________ _____ ________
|
|
60
|
+
| / |/ / __ `/ / ___/ ___/ __ `/ __ \\/ ___/ _ \\
|
|
61
|
+
| / /| / /_/ / (__ |__ ) /_/ / / / / /__/ __/
|
|
62
|
+
| /_/ |_/\\__,_/_/____/____/\\__,_/_/ /_/\\___/\\___/
|
|
63
|
+
| v2.0.0
|
|
64
|
+
|
|
|
65
|
+
| Git Repository Genesis Utility
|
|
66
|
+
┼─────────────────────────────────────────────────────────────┼{_RESET}
|
|
67
|
+
""")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Preflight error helpers ───────────────────────────────────────────
|
|
71
|
+
# Each checks a single condition and calls fail() if it's not met.
|
|
72
|
+
|
|
73
|
+
def require_tool(tool: str, friendly: str | None = None) -> None:
|
|
74
|
+
"""Verify a CLI tool is available on PATH."""
|
|
75
|
+
import shutil
|
|
76
|
+
|
|
77
|
+
label = friendly or tool
|
|
78
|
+
if shutil.which(tool) is None:
|
|
79
|
+
fail(f"{label} is not installed or not on PATH")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def require_gh_auth() -> None:
|
|
83
|
+
"""Verify the GitHub CLI is authenticated."""
|
|
84
|
+
import subprocess
|
|
85
|
+
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["gh", "auth", "status"],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
errors="replace",
|
|
92
|
+
)
|
|
93
|
+
if result.returncode != 0:
|
|
94
|
+
fail("GitHub CLI is not authenticated. Run: gh auth login")
|
naissance/git_ops.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
git_ops.py — Git and GitHub CLI subprocess wrappers.
|
|
3
|
+
|
|
4
|
+
Every function routes errors through the centralized ErrorHandler so
|
|
5
|
+
that raw tracebacks never leak into the user's terminal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from naissance import errors
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(
|
|
15
|
+
cmd: list[str],
|
|
16
|
+
*,
|
|
17
|
+
cwd: Path | None = None,
|
|
18
|
+
check: bool = True,
|
|
19
|
+
label: str | None = None,
|
|
20
|
+
) -> subprocess.CompletedProcess[str]:
|
|
21
|
+
"""
|
|
22
|
+
Run a subprocess, suppressing raw output.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
cmd : list[str]
|
|
27
|
+
The command and its arguments.
|
|
28
|
+
cwd : Path | None
|
|
29
|
+
Working directory for the command.
|
|
30
|
+
check : bool
|
|
31
|
+
If True, call errors.fail() on non-zero exit.
|
|
32
|
+
label : str | None
|
|
33
|
+
Human-readable label for error messages (e.g. "git init").
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
subprocess.CompletedProcess
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
cmd,
|
|
42
|
+
cwd=cwd,
|
|
43
|
+
text=True,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
errors="replace",
|
|
47
|
+
)
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
errors.fail(f"{cmd[0]} is not installed or not on PATH")
|
|
50
|
+
except PermissionError:
|
|
51
|
+
errors.fail(f"Permission denied while running: {' '.join(cmd)}")
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
# Catch-all — no raw tracebacks should ever leak
|
|
54
|
+
errors.fail(f"Unexpected error running {cmd[0]}: {exc}")
|
|
55
|
+
|
|
56
|
+
if check and result.returncode != 0:
|
|
57
|
+
detail = result.stderr.strip() or result.stdout.strip() or "unknown error"
|
|
58
|
+
tag = label or " ".join(cmd[:2])
|
|
59
|
+
errors.fail(f"{tag} failed: {detail}")
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Git operations ────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def git_init(path: Path) -> None:
|
|
67
|
+
"""Initialize a git repository at *path*."""
|
|
68
|
+
_run(["git", "init"], cwd=path, label="git init")
|
|
69
|
+
errors.success("Git initialized")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def git_add(path: Path, *targets: str) -> None:
|
|
73
|
+
"""Stage files. Pass '.' to stage everything."""
|
|
74
|
+
_run(["git", "add", *targets], cwd=path, label="git add")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def git_commit(path: Path, message: str) -> None:
|
|
78
|
+
"""Create a commit with *message*."""
|
|
79
|
+
_run(["git", "commit", "-m", message], cwd=path, label="git commit")
|
|
80
|
+
errors.success("Initial commit")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def git_push(path: Path, branch: str) -> None:
|
|
84
|
+
"""Push to origin with upstream tracking."""
|
|
85
|
+
_run(["git", "push", "-u", "origin", branch], cwd=path, label="git push")
|
|
86
|
+
errors.success(f"Pushed to {branch}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def git_branch_current(path: Path, default: str = "main") -> str:
|
|
90
|
+
"""Return the current branch name, falling back to *default*."""
|
|
91
|
+
result = _run(
|
|
92
|
+
["git", "branch", "--show-current"],
|
|
93
|
+
cwd=path,
|
|
94
|
+
check=False,
|
|
95
|
+
label="git branch",
|
|
96
|
+
)
|
|
97
|
+
branch = result.stdout.strip() if result.returncode == 0 else ""
|
|
98
|
+
return branch or default
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def git_has_remote(path: Path, name: str = "origin") -> bool:
|
|
102
|
+
"""Check whether a git remote named *name* already exists."""
|
|
103
|
+
result = _run(
|
|
104
|
+
["git", "remote", "get-url", name],
|
|
105
|
+
cwd=path,
|
|
106
|
+
check=False,
|
|
107
|
+
label="git remote",
|
|
108
|
+
)
|
|
109
|
+
return result.returncode == 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── GitHub CLI operations ─────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def gh_repo_create_clone(
|
|
115
|
+
name: str,
|
|
116
|
+
*,
|
|
117
|
+
visibility: str = "private",
|
|
118
|
+
clone_into: Path | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Create a remote repo and clone it locally.
|
|
122
|
+
|
|
123
|
+
Used when the target directory does NOT exist or is EMPTY.
|
|
124
|
+
"""
|
|
125
|
+
vis_flag = "--public" if visibility == "public" else "--private"
|
|
126
|
+
cmd = ["gh", "repo", "create", name, vis_flag, "--clone"]
|
|
127
|
+
|
|
128
|
+
if clone_into is not None:
|
|
129
|
+
# gh repo create <name> --clone clones into ./<name>
|
|
130
|
+
# We run it from the parent so the folder lands at the right spot.
|
|
131
|
+
_run(cmd, cwd=clone_into.parent, label="gh repo create --clone")
|
|
132
|
+
else:
|
|
133
|
+
_run(cmd, label="gh repo create --clone")
|
|
134
|
+
|
|
135
|
+
errors.success(f"GitHub repository created ({visibility})")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def gh_repo_create_source(
|
|
139
|
+
path: Path,
|
|
140
|
+
name: str,
|
|
141
|
+
*,
|
|
142
|
+
visibility: str = "private",
|
|
143
|
+
push: bool = True,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Create a remote repo from an existing local repo.
|
|
147
|
+
|
|
148
|
+
Used when the target directory already has files / is git-initialized.
|
|
149
|
+
"""
|
|
150
|
+
vis_flag = "--public" if visibility == "public" else "--private"
|
|
151
|
+
cmd = ["gh", "repo", "create", name, vis_flag, "--source=."]
|
|
152
|
+
if push:
|
|
153
|
+
cmd.append("--push")
|
|
154
|
+
|
|
155
|
+
_run(cmd, cwd=path, label="gh repo create --source")
|
|
156
|
+
errors.success(f"GitHub repository created ({visibility})")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def gh_repo_exists(name: str) -> bool:
|
|
160
|
+
"""Check whether a remote repository with *name* already exists."""
|
|
161
|
+
result = _run(
|
|
162
|
+
["gh", "repo", "view", name],
|
|
163
|
+
check=False,
|
|
164
|
+
label="gh repo view",
|
|
165
|
+
)
|
|
166
|
+
return result.returncode == 0
|
naissance/main.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
path_resolver.py — Resolve CLI input to a concrete (Path, repo_name) pair.
|
|
3
|
+
|
|
4
|
+
Resolution rules:
|
|
5
|
+
naissance horizon → CWD / "horizon", repo_name = "horizon"
|
|
6
|
+
naissance code/horizon → CWD / "code/horizon", repo_name = "horizon"
|
|
7
|
+
naissance . → CWD, repo_name = CWD folder name
|
|
8
|
+
naissance "C:\\x\\y" --apath → Path("C:\\x\\y"), repo_name = "y"
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from naissance import errors
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve(repo_name: str, *, apath: bool = False) -> tuple[Path, str]:
|
|
17
|
+
"""
|
|
18
|
+
Resolve *repo_name* into an absolute Path and a clean repo name.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
repo_name : str
|
|
23
|
+
The raw positional argument from the CLI.
|
|
24
|
+
apath : bool
|
|
25
|
+
If True, treat *repo_name* as an absolute path.
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
(resolved_path, repo_name)
|
|
30
|
+
The fully-resolved directory path and the repository name
|
|
31
|
+
(last component of the path).
|
|
32
|
+
"""
|
|
33
|
+
raw = repo_name.strip().strip('"').strip("'")
|
|
34
|
+
|
|
35
|
+
# ── Dot notation: use CWD itself ──────────────────────────────────
|
|
36
|
+
if raw in (".", "./", ".\\"):
|
|
37
|
+
resolved = Path.cwd().resolve()
|
|
38
|
+
name = resolved.name
|
|
39
|
+
return resolved, name
|
|
40
|
+
|
|
41
|
+
# ── Absolute path mode ────────────────────────────────────────────
|
|
42
|
+
if apath:
|
|
43
|
+
resolved = Path(raw).resolve()
|
|
44
|
+
else:
|
|
45
|
+
# Default: relative to CWD
|
|
46
|
+
resolved = (Path.cwd() / raw).resolve()
|
|
47
|
+
|
|
48
|
+
name = resolved.name
|
|
49
|
+
|
|
50
|
+
if not name:
|
|
51
|
+
errors.fail(f"Could not determine repository name from path: {resolved}")
|
|
52
|
+
|
|
53
|
+
# Validate that the *parent* directory exists (the repo dir itself
|
|
54
|
+
# may be created later). For absolute paths the parent must exist;
|
|
55
|
+
# for relative paths CWD is always valid so this is a safety net.
|
|
56
|
+
parent = resolved.parent
|
|
57
|
+
if not parent.exists():
|
|
58
|
+
errors.fail(f"Parent directory does not exist: {parent}")
|
|
59
|
+
if parent.exists() and not parent.is_dir():
|
|
60
|
+
errors.fail(f"Parent path is not a directory: {parent}")
|
|
61
|
+
|
|
62
|
+
return resolved, name
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: naissance
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Git Repository Genesis Utility
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/DODO-unique/Naissance
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: pyyaml>=6.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Naissance
|
|
18
|
+
|
|
19
|
+
> *A more dramatic Git Repository Genesis Utility.*
|
|
20
|
+
|
|
21
|
+
Naissance creates both local and remote repositories in one shot. No context switching between terminal and browser — just run one command and your repo is alive.
|
|
22
|
+
|
|
23
|
+
<!--  -->
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Philosophy
|
|
27
|
+
|
|
28
|
+
Naissance is meant to **get out of the way**. The goal is always to minimize friction between "I want a repo" and "the repo exists." No prompts, no decisions at runtime, no context switching. You type one command, naissance handles the rest, and you're already writing code. Every design choice should serve this principle.
|
|
29
|
+
|
|
30
|
+
<!-- TODO: Add hero banner/screenshot here -->
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Clone and install
|
|
38
|
+
git clone https://github.com/DODO-unique/Naissance.git
|
|
39
|
+
cd Naissance
|
|
40
|
+
pip install .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Requirements
|
|
44
|
+
|
|
45
|
+
- Python ≥ 3.10
|
|
46
|
+
- [Git](https://git-scm.com/)
|
|
47
|
+
- [GitHub CLI (`gh`)](https://cli.github.com/) — authenticated via `gh auth login`
|
|
48
|
+
|
|
49
|
+
> **Note:** `gh` is a crucial dependency — naissance uses it to create remote repositories. Without it, the tool cannot function. If `gh` is not installed, naissance will detect this at startup and suggest how to install it:
|
|
50
|
+
> ```
|
|
51
|
+
> ⨯ GitHub CLI (gh) is not installed or not on PATH
|
|
52
|
+
> ```
|
|
53
|
+
> Install it from [cli.github.com](https://cli.github.com/), then run `gh auth login` to authenticate.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Create a new repo "horizon" in the current directory
|
|
61
|
+
naissance horizon
|
|
62
|
+
|
|
63
|
+
# That's it. Local + remote, done.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
<!-- TODO: Add terminal recording / screenshot of output here -->
|
|
67
|
+
<!--  -->
|
|
68
|
+
|
|
69
|
+
### What happens
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
┼─────────────────────────────────────────────────────────────┼
|
|
73
|
+
| _ __ _
|
|
74
|
+
| / | / /___ _(_)_____________ _____ ________
|
|
75
|
+
| / |/ / __ `/ / ___/ ___/ __ `/ __ \/ ___/ _ \
|
|
76
|
+
| / /| / /_/ / (__ |__ ) /_/ / / / / /__/ __/
|
|
77
|
+
| /_/ |_/\__,_/_/____/____/\__,_/_/ /_/\___/\___/
|
|
78
|
+
| v2.0.0
|
|
79
|
+
|
|
|
80
|
+
| Git Repository Genesis Utility
|
|
81
|
+
┼─────────────────────────────────────────────────────────────┼
|
|
82
|
+
|
|
83
|
+
Birthing repository...
|
|
84
|
+
|
|
85
|
+
✓ git found
|
|
86
|
+
✓ gh authenticated
|
|
87
|
+
✓ Path resolved: C:\Code\horizon
|
|
88
|
+
✓ Directory created
|
|
89
|
+
✓ GitHub repository created (private)
|
|
90
|
+
✓ Git initialized
|
|
91
|
+
✓ README.md
|
|
92
|
+
✓ .gitignore
|
|
93
|
+
✓ Initial commit
|
|
94
|
+
✓ Pushed to main
|
|
95
|
+
|
|
96
|
+
✨ horizon is alive.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Usage
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
naissance <repo_name> [--apath] [--name NAME] [--public]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Flag | Description |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `repo_name` | Name or path of the repo. Resolved relative to CWD. Use `.` for current directory. |
|
|
110
|
+
| `--apath` | Treat `repo_name` as an absolute path |
|
|
111
|
+
| `--name NAME` | Override the remote repository name (defaults to directory name) |
|
|
112
|
+
| `--public` | Create the remote repo as public (default: private) |
|
|
113
|
+
| `-v, --version` | Show version |
|
|
114
|
+
|
|
115
|
+
### Examples
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Relative path — creates ./horizon
|
|
119
|
+
naissance horizon
|
|
120
|
+
|
|
121
|
+
# Nested path — creates ./projects/horizon
|
|
122
|
+
naissance projects/horizon
|
|
123
|
+
|
|
124
|
+
# Current directory — uses folder name as repo name
|
|
125
|
+
naissance .
|
|
126
|
+
|
|
127
|
+
# Absolute path
|
|
128
|
+
naissance "C:\Code\horizon" --apath
|
|
129
|
+
|
|
130
|
+
# Different remote name than the directory
|
|
131
|
+
naissance horizon --name my-cool-project
|
|
132
|
+
|
|
133
|
+
# Public repo
|
|
134
|
+
naissance horizon --public
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## How It Works
|
|
140
|
+
|
|
141
|
+
Naissance detects the state of the target directory and adapts:
|
|
142
|
+
|
|
143
|
+
| State | What naissance does |
|
|
144
|
+
|---|---|
|
|
145
|
+
| **Doesn't exist** | Creates the directory → `gh repo create --clone` → scaffolds README + .gitignore → commits → pushes |
|
|
146
|
+
| **Empty directory** | `git init` → scaffolds → `gh repo create --source` → pushes |
|
|
147
|
+
| **Has files, no git** | `git init` → scaffolds → stages all → commits → `gh repo create --source` → pushes |
|
|
148
|
+
| **Already git-initialized** | Checks no remote exists → `gh repo create --source` → pushes |
|
|
149
|
+
|
|
150
|
+
All decisions (visibility, scaffold files, commit message) are driven by [`defaults.yml`](./defaults.yml) — **zero interactive prompts** during execution.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Configuration
|
|
155
|
+
|
|
156
|
+
Edit `defaults.yml` next to the package to change defaults:
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
version: "2.0.0"
|
|
160
|
+
visibility: "private" # "private" or "public"
|
|
161
|
+
create_readme: true
|
|
162
|
+
create_gitignore: true
|
|
163
|
+
prompt_description: false
|
|
164
|
+
default_branch: "main"
|
|
165
|
+
initial_commit_message: "initial commit"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Error Handling
|
|
171
|
+
|
|
172
|
+
Naissance never leaks raw tracebacks. All errors are caught and displayed cleanly:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
⨯ git is not installed or not on PATH
|
|
176
|
+
⨯ GitHub CLI is not authenticated. Run: gh auth login
|
|
177
|
+
⨯ Remote repository already exists: horizon
|
|
178
|
+
⨯ Permission denied: C:\Protected\path
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
[MIT](./LICENSE)
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## See Also
|
|
190
|
+
|
|
191
|
+
- [CHANGELOG.md](./CHANGELOG.md) — Version history
|
|
192
|
+
- [ROADMAP.md](./ROADMAP.md) — What's coming next
|
|
193
|
+
- [KNOWN_ISSUES.md](./KNOWN_ISSUES.md) — Bugs and workarounds
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
naissance/__init__.py,sha256=k6FrpPbBT4qc4UaIJ0kEP2dmTffqu4HH54MxnriLZVw,75
|
|
2
|
+
naissance/__main__.py,sha256=bFjYJ-yK5LcwFu9B6NiCsirX25944Orq2DsYEl4d99A,134
|
|
3
|
+
naissance/cli.py,sha256=gPPxNMGcVgEKehlEZI5DfnSii4ZvxbfPczsTHO80Lns,1748
|
|
4
|
+
naissance/core.py,sha256=5vwxo66gqZH8YZDvkgThENSKva5L0HzKH_nK7XA9w4U,8856
|
|
5
|
+
naissance/errors.py,sha256=QB1dg6iDayjZQonMRtE8kb3_Wap-2u3r9Q14EKXk23g,3166
|
|
6
|
+
naissance/git_ops.py,sha256=AVtGWG0vyJU3l87D4QEc5OIwIXtNfy3a1yqAjNomM3I,5026
|
|
7
|
+
naissance/main.py,sha256=PoUCMlYcTu69uqZ63txJQqk-4W8xrCSzwZgndRcp8SI,218
|
|
8
|
+
naissance/path_resolver.py,sha256=pMsN3gk3ohSBEjeMblZiVP1IlvqQk45ygonxO5osP6Q,2224
|
|
9
|
+
naissance-2.0.0.dist-info/licenses/LICENSE,sha256=hDNBiwtJIJpBVn9UydxtyCD0UWfgeL_DSLF4VeuMZ_o,1071
|
|
10
|
+
naissance-2.0.0.dist-info/METADATA,sha256=6c1twIycYIT0wjywMgY1_v-Z1Ct99EKj9tcwF6pA6G0,5770
|
|
11
|
+
naissance-2.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
naissance-2.0.0.dist-info/entry_points.txt,sha256=h5g7DSITd1UpnKtZcWf4Io3cF7h5KN4oXmSoTXnrst4,50
|
|
13
|
+
naissance-2.0.0.dist-info/top_level.txt,sha256=ZOhXtHEDhA2i6Aa_pgAvGOjrg70We8AOn2CozMUiaSI,10
|
|
14
|
+
naissance-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026 DODO-unique
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
naissance
|