git-epic 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.
git_epic/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Epic-based Git workflows."""
2
+
3
+ __version__ = "0.1.0"
git_epic/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
git_epic/cli.py ADDED
@@ -0,0 +1,56 @@
1
+ """Command-line entry point, available as git-epic and git epic."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__, workflow
7
+ from .process import EpicError
8
+
9
+
10
+ def parser() -> argparse.ArgumentParser:
11
+ root = argparse.ArgumentParser(
12
+ prog="git epic", description="Epic-based Git workflows and GitLab merge requests"
13
+ )
14
+ root.add_argument("--version", "-V", action="version", version=__version__)
15
+ commands = root.add_subparsers(dest="command", required=True)
16
+ init = commands.add_parser("init", help="Configure this repository")
17
+ init.add_argument("--production", default="production", metavar="BRANCH")
18
+ init.add_argument("--remote", default="origin")
19
+ start = commands.add_parser("start", help="Create an epic from latest production")
20
+ start.add_argument("name")
21
+ task = commands.add_parser("task", help="Work on tasks within an epic")
22
+ tasks = task.add_subparsers(dest="task_command", required=True)
23
+ task_start = tasks.add_parser("start", help="Create a task from the current epic")
24
+ task_start.add_argument("name")
25
+ tasks.add_parser("submit", help="Push the task and create or return its epic MR")
26
+ commands.add_parser("sync", help="Merge latest production into the current epic")
27
+ commands.add_parser("status", help="Show branch context, production drift, MRs, and CI")
28
+ commands.add_parser(
29
+ "release",
30
+ help="Push the epic and create or return its production MR; never merge or deploy",
31
+ )
32
+ return root
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ args = parser().parse_args(argv)
37
+ try:
38
+ if args.command == "init":
39
+ workflow.init(args.production, args.remote)
40
+ elif args.command == "start":
41
+ workflow.start(args.name)
42
+ elif args.command == "task":
43
+ if args.task_command == "start":
44
+ workflow.task_start(args.name)
45
+ else:
46
+ workflow.task_submit()
47
+ else:
48
+ {"sync": workflow.sync, "status": workflow.status, "release": workflow.release}[
49
+ args.command
50
+ ]()
51
+ except (EpicError, OSError) as error:
52
+ print(f"git-epic: {error}", file=sys.stderr)
53
+ return 1
54
+ except KeyboardInterrupt:
55
+ return 130
56
+ return 0
git_epic/config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Shared repository settings; credentials remain managed by Git and glab."""
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+
6
+ from .git import Git
7
+ from .process import EpicError
8
+
9
+ CONFIG_FILE = ".git-epic.json"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Config:
14
+ production_branch: str = "production"
15
+ remote: str = "origin"
16
+ hosting: str = "gitlab"
17
+
18
+ def validate(self, git: Git) -> None:
19
+ git.validate_branch(self.production_branch)
20
+ git.validate_remote(self.remote)
21
+
22
+
23
+ def load_config(git: Git) -> Config:
24
+ path = git.root / CONFIG_FILE
25
+ if not path.exists():
26
+ raise EpicError("Run git epic init first.")
27
+ try:
28
+ data = json.loads(path.read_text(encoding="utf-8"))
29
+ except json.JSONDecodeError as error:
30
+ raise EpicError(f"Invalid {CONFIG_FILE}: {error.msg}.") from error
31
+ if (
32
+ not isinstance(data, dict)
33
+ or not isinstance(data.get("productionBranch"), str)
34
+ or not isinstance(data.get("remote"), str)
35
+ or data.get("hosting") != "gitlab"
36
+ ):
37
+ raise EpicError(
38
+ f"Invalid {CONFIG_FILE}: expected productionBranch, remote, and hosting: gitlab."
39
+ )
40
+ config = Config(data["productionBranch"], data["remote"])
41
+ config.validate(git)
42
+ return config
43
+
44
+
45
+ def save_config(git: Git, config: Config) -> None:
46
+ path = git.root / CONFIG_FILE
47
+ if path.exists():
48
+ raise EpicError(f"{CONFIG_FILE} already exists. Edit it to change configuration.")
49
+ config.validate(git)
50
+ git.fetch(config.remote)
51
+ git.remote_ref(config.remote, config.production_branch)
52
+ data = asdict(config)
53
+ data["productionBranch"] = data.pop("production_branch")
54
+ with path.open("x", encoding="utf-8") as file:
55
+ file.write(json.dumps(data, indent=2) + "\n")
git_epic/git.py ADDED
@@ -0,0 +1,74 @@
1
+ """Git operations used by the workflow."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .process import EpicError, run
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Divergence:
11
+ behind: int
12
+ ahead: int
13
+
14
+
15
+ class Git:
16
+ def __init__(self, cwd: Path | None = None):
17
+ self.root = Path(run("git", ["rev-parse", "--show-toplevel"], cwd or Path.cwd()))
18
+
19
+ def exec(self, *args: str) -> str:
20
+ return run("git", list(args), self.root)
21
+
22
+ def dirty(self) -> bool:
23
+ return bool(
24
+ self.exec("status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none")
25
+ )
26
+
27
+ def clean(self) -> None:
28
+ if self.dirty():
29
+ raise EpicError("Working tree has uncommitted changes. Commit or stash them first.")
30
+ if any(self.exists(ref) for ref in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD")):
31
+ raise EpicError("Finish the in-progress Git operation first.")
32
+ for state in ("rebase-merge", "rebase-apply", "sequencer"):
33
+ path = self.exec("rev-parse", "--path-format=absolute", "--git-path", state)
34
+ if Path(path).exists():
35
+ raise EpicError("Finish the in-progress Git operation first.")
36
+
37
+ def branch(self) -> str:
38
+ branch = self.exec("branch", "--show-current")
39
+ if not branch:
40
+ raise EpicError("Detached HEAD: check out an epic or task branch first.")
41
+ return branch
42
+
43
+ def exists(self, ref: str) -> bool:
44
+ try:
45
+ self.exec("rev-parse", "--verify", "--quiet", ref)
46
+ except EpicError:
47
+ return False
48
+ return True
49
+
50
+ def validate_branch(self, branch: str) -> None:
51
+ self.exec("check-ref-format", "--branch", branch)
52
+
53
+ def validate_remote(self, remote: str) -> None:
54
+ if remote.startswith("-") or remote not in self.exec("remote").splitlines():
55
+ raise EpicError(f"Remote '{remote}' does not exist or has an unsupported name.")
56
+
57
+ def fetch(self, remote: str) -> None:
58
+ self.exec("fetch", "--prune", remote, f"+refs/heads/*:refs/remotes/{remote}/*")
59
+
60
+ def remote_ref(self, remote: str, branch: str) -> str:
61
+ ref = f"refs/remotes/{remote}/{branch}"
62
+ if not self.exists(ref):
63
+ raise EpicError(f"Branch '{branch}' does not exist on '{remote}'.")
64
+ return ref
65
+
66
+ def create(self, branch: str, base: str, remote: str) -> None:
67
+ self.validate_branch(branch)
68
+ if self.exists(f"refs/heads/{branch}") or self.exists(f"refs/remotes/{remote}/{branch}"):
69
+ raise EpicError(f"Branch '{branch}' already exists. Check it out instead.")
70
+ self.exec("switch", "--no-track", "-c", branch, base)
71
+
72
+ def divergence(self, base: str, head: str = "HEAD") -> Divergence:
73
+ behind, ahead = self.exec("rev-list", "--left-right", "--count", f"{base}...{head}").split()
74
+ return Divergence(int(behind), int(ahead))
git_epic/gitlab.py ADDED
@@ -0,0 +1,109 @@
1
+ """GitLab merge requests through the authenticated glab CLI."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from .git import Git
8
+ from .process import EpicError, run
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class MergeRequest:
13
+ iid: int
14
+ web_url: str
15
+ source_branch: str
16
+ target_branch: str
17
+ source_project_id: int
18
+ pipeline_status: str
19
+
20
+ @classmethod
21
+ def from_json(cls, data: Any) -> "MergeRequest":
22
+ if not isinstance(data, dict):
23
+ raise EpicError("Unexpected merge-request response from glab.")
24
+ for field in ("iid", "source_project_id"):
25
+ if type(data.get(field)) is not int:
26
+ raise EpicError(f"Unexpected merge-request {field} from glab.")
27
+ for field in ("web_url", "source_branch", "target_branch"):
28
+ if not isinstance(data.get(field), str):
29
+ raise EpicError(f"Unexpected merge-request {field} from glab.")
30
+ pipeline = data.get("head_pipeline") or {}
31
+ status = pipeline.get("status") if isinstance(pipeline, dict) else None
32
+ return cls(
33
+ data["iid"],
34
+ data["web_url"],
35
+ data["source_branch"],
36
+ data["target_branch"],
37
+ data["source_project_id"],
38
+ status or "no pipeline reported",
39
+ )
40
+
41
+
42
+ class GitLab:
43
+ def __init__(self, git: Git, remote: str):
44
+ self.git = git
45
+ self.repo = git.exec("config", "--get", f"remote.{remote}.url")
46
+ self._project_id: int | None = None
47
+
48
+ def exec(self, *args: str) -> str:
49
+ return run("glab", [*args, "--repo", self.repo], self.git.root)
50
+
51
+ @staticmethod
52
+ def decode(output: str) -> Any:
53
+ try:
54
+ return json.loads(output)
55
+ except json.JSONDecodeError as error:
56
+ raise EpicError("Unexpected JSON response from glab.") from error
57
+
58
+ def project_id(self) -> int:
59
+ if self._project_id is None:
60
+ project = self.decode(
61
+ run("glab", ["repo", "view", self.repo, "--output", "json"], self.git.root)
62
+ )
63
+ if not isinstance(project, dict) or type(project.get("id")) is not int:
64
+ raise EpicError("Unexpected project response from glab.")
65
+ self._project_id = project["id"]
66
+ return self._project_id
67
+
68
+ def list(self, source: str | None = None, target: str | None = None) -> list[MergeRequest]:
69
+ args = ["mr", "list", "--output", "json", "--per-page", "100"]
70
+ if source:
71
+ args.extend(["--source-branch", source])
72
+ if target:
73
+ args.extend(["--target-branch", target])
74
+ requests = []
75
+ page = 1
76
+ while True:
77
+ result = self.decode(self.exec(*args, "--page", str(page)))
78
+ if not isinstance(result, list):
79
+ raise EpicError("Unexpected merge-request response from glab.")
80
+ for data in result:
81
+ mr = MergeRequest.from_json(data)
82
+ # Deduplication matches our source project; epic status includes fork MRs.
83
+ if not source or mr.source_project_id == self.project_id():
84
+ requests.append(mr)
85
+ if len(result) < 100:
86
+ return requests
87
+ page += 1
88
+
89
+ def details(self, iid: int) -> MergeRequest:
90
+ return MergeRequest.from_json(
91
+ self.decode(self.exec("mr", "view", str(iid), "--output", "json"))
92
+ )
93
+
94
+ def create(self, source: str, target: str, title: str, description: str) -> str:
95
+ return self.exec(
96
+ "mr",
97
+ "create",
98
+ "--head",
99
+ self.repo,
100
+ "--source-branch",
101
+ source,
102
+ "--target-branch",
103
+ target,
104
+ "--title",
105
+ title,
106
+ "--description",
107
+ description,
108
+ "--yes",
109
+ )
git_epic/process.py ADDED
@@ -0,0 +1,30 @@
1
+ """Shell-free execution of Git and glab."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+
8
+ class EpicError(RuntimeError):
9
+ """An actionable workflow or subprocess failure."""
10
+
11
+
12
+ def run(command: str, args: list[str], cwd: Path) -> str:
13
+ try:
14
+ result = subprocess.run(
15
+ [command, *args],
16
+ cwd=cwd,
17
+ text=True,
18
+ encoding="utf-8",
19
+ errors="replace",
20
+ capture_output=True,
21
+ env={**os.environ, "GLAB_CHECK_UPDATE": "false", "GIT_TERMINAL_PROMPT": "0"},
22
+ )
23
+ except OSError as error:
24
+ raise EpicError(
25
+ f"Cannot run {command}: {error}. Ensure it is installed and on PATH."
26
+ ) from error
27
+ if result.returncode:
28
+ detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}"
29
+ raise EpicError(f"{command} {args[0] if args else ''} failed:\n{detail}")
30
+ return result.stdout.strip()
git_epic/workflow.py ADDED
@@ -0,0 +1,178 @@
1
+ """Team branching and MR workflow. QA and deployments remain external."""
2
+
3
+ import re
4
+
5
+ from .config import CONFIG_FILE, Config, load_config, save_config
6
+ from .git import Git
7
+ from .gitlab import GitLab
8
+ from .process import EpicError
9
+
10
+ TASK_PATTERN = re.compile(r"task/([^/]+)/([^/]+)")
11
+
12
+
13
+ def epic_name(branch: str) -> str:
14
+ if not branch.startswith("epic/") or not branch[5:] or "/" in branch[5:]:
15
+ raise EpicError("This command requires an epic/<name> branch.")
16
+ return branch[5:]
17
+
18
+
19
+ def name(value: str) -> str:
20
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", value):
21
+ raise EpicError(
22
+ "Names must start with a letter or number and contain only letters, "
23
+ "numbers, dots, underscores, or hyphens."
24
+ )
25
+ return value
26
+
27
+
28
+ def context() -> tuple[Git, Config]:
29
+ git = Git()
30
+ return git, load_config(git)
31
+
32
+
33
+ def init(production: str = "production", remote: str = "origin") -> None:
34
+ git = Git()
35
+ git.clean()
36
+ save_config(git, Config(production, remote))
37
+ print(f"Created {CONFIG_FILE}. Commit it so the team shares these settings.")
38
+
39
+
40
+ def start(value: str) -> None:
41
+ git, config = context()
42
+ branch = f"epic/{name(value)}"
43
+ git.clean()
44
+ git.fetch(config.remote)
45
+ git.create(branch, git.remote_ref(config.remote, config.production_branch), config.remote)
46
+ print(f"Switched to {branch}.")
47
+
48
+
49
+ def task_start(value: str) -> None:
50
+ git, config = context()
51
+ parent = f"epic/{epic_name(git.branch())}"
52
+ branch = f"task/{epic_name(parent)}/{name(value)}"
53
+ git.clean()
54
+ git.fetch(config.remote)
55
+ if git.exists(f"refs/remotes/{config.remote}/{parent}"):
56
+ git.exec("merge", "--ff-only", git.remote_ref(config.remote, parent))
57
+ git.create(branch, "HEAD", config.remote)
58
+ print(f"Switched to {branch}; parent: {parent}.")
59
+
60
+
61
+ def sync() -> None:
62
+ git, config = context()
63
+ epic_name(git.branch())
64
+ git.clean()
65
+ git.fetch(config.remote)
66
+ try:
67
+ git.exec("merge", "--no-edit", git.remote_ref(config.remote, config.production_branch))
68
+ except EpicError as error:
69
+ raise EpicError(
70
+ f"{error}\nResolve any conflicts and commit, or use git merge --abort. "
71
+ "Then rerun git epic sync."
72
+ ) from error
73
+ print("Epic synchronized with production. Push it to share the changes.")
74
+
75
+
76
+ def submit(source: str, target: str, git: Git, config: Config, *, is_release: bool) -> None:
77
+ git.clean()
78
+ git.fetch(config.remote)
79
+ target_ref = git.remote_ref(config.remote, target)
80
+ drift = git.divergence(target_ref)
81
+ if is_release and drift.behind:
82
+ raise EpicError(
83
+ "Epic is behind production. Run git epic sync and revalidate "
84
+ "the updated candidate with QA."
85
+ )
86
+ hosting = GitLab(git, config.remote)
87
+ existing = hosting.list(source, target)
88
+ if not existing and not drift.ahead:
89
+ raise EpicError(f"No new commits to submit to {target}.")
90
+ git.exec("push", "--set-upstream", config.remote, f"HEAD:refs/heads/{source}")
91
+ if existing:
92
+ print(f"Existing merge request: {existing[0].web_url}")
93
+ return
94
+ if is_release:
95
+ title = f"Release {epic_name(source)}"
96
+ description = (
97
+ f"Release {source} into {target}.\n\n"
98
+ f"Candidate commit: {git.exec('rev-parse', 'HEAD')}\n\n"
99
+ "Required CI, reviews, and QA approval must be verified before merging. "
100
+ "This command does not merge or deploy."
101
+ )
102
+ else:
103
+ title = f"Task: {source.split('/')[-1]}"
104
+ description = f"Merge {source} into its parent epic {target}."
105
+ try:
106
+ print(hosting.create(source, target, title, description))
107
+ except EpicError as creation_error:
108
+ # Another teammate may have created the MR between lookup and creation.
109
+ try:
110
+ concurrent = hosting.list(source, target)
111
+ except EpicError:
112
+ raise creation_error from None
113
+ if not concurrent:
114
+ raise
115
+ print(f"Existing merge request: {concurrent[0].web_url}")
116
+
117
+
118
+ def task_submit() -> None:
119
+ git, config = context()
120
+ source = git.branch()
121
+ match = TASK_PATTERN.fullmatch(source)
122
+ if not match:
123
+ raise EpicError("This command requires a task/<epic>/<name> branch.")
124
+ parent = f"epic/{match[1]}"
125
+ git.clean()
126
+ git.fetch(config.remote)
127
+ if not git.exists(f"refs/remotes/{config.remote}/{parent}"):
128
+ if not git.exists(f"refs/heads/{parent}"):
129
+ raise EpicError(
130
+ f"Parent {parent} is missing locally and remotely. Restore or publish it first."
131
+ )
132
+ git.exec("push", config.remote, f"refs/heads/{parent}:refs/heads/{parent}")
133
+ git.fetch(config.remote)
134
+ submit(source, parent, git, config, is_release=False)
135
+
136
+
137
+ def release() -> None:
138
+ git, config = context()
139
+ source = git.branch()
140
+ epic_name(source)
141
+ submit(source, config.production_branch, git, config, is_release=True)
142
+
143
+
144
+ def status() -> None:
145
+ git, config = context()
146
+ branch = git.branch()
147
+ task = TASK_PATTERN.fullmatch(branch)
148
+ epic = f"epic/{task[1] if task else epic_name(branch)}"
149
+ git.fetch(config.remote)
150
+ production_ref = git.remote_ref(config.remote, config.production_branch)
151
+ print(f"Branch: {branch}\nEpic: {epic}\nProduction: {config.remote}/{config.production_branch}")
152
+ local_epic = f"refs/heads/{epic}"
153
+ remote_epic = f"refs/remotes/{config.remote}/{epic}"
154
+ for label, ref in (
155
+ ("Local epic vs production", local_epic),
156
+ ("Remote epic vs production", remote_epic),
157
+ ):
158
+ if not git.exists(ref):
159
+ print(f"{label}: not present")
160
+ continue
161
+ drift = git.divergence(production_ref, ref)
162
+ print(f"{label}: {drift.behind} behind, {drift.ahead} ahead")
163
+ if git.exists(local_epic) and git.exists(remote_epic):
164
+ drift = git.divergence(remote_epic, local_epic)
165
+ print(f"Local epic vs remote epic: {drift.behind} behind, {drift.ahead} ahead")
166
+ if git.dirty():
167
+ print("Working tree: uncommitted changes")
168
+ hosting = GitLab(git, config.remote)
169
+ requests = hosting.list(branch if task else None, epic)
170
+ requests.extend(hosting.list(epic, config.production_branch))
171
+ if not requests:
172
+ print("No open merge requests for this task/epic.")
173
+ for mr in requests:
174
+ details = hosting.details(mr.iid)
175
+ print(
176
+ f"MR !{mr.iid}: {mr.source_branch} → {mr.target_branch}\n"
177
+ f"{mr.web_url}\nCI: {details.pipeline_status}"
178
+ )
@@ -0,0 +1,205 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-epic
3
+ Version: 0.1.0
4
+ Summary: Epic-based Git workflows and GitLab merge requests
5
+ Author: Wonderteam
6
+ Project-URL: Repository, https://gitlab.wonderbeauties.com/wonderteam/git-epic
7
+ Project-URL: Issues, https://gitlab.wonderbeauties.com/wonderteam/git-epic/-/issues
8
+ Keywords: git,cli,gitlab,epic,workflow
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Provides-Extra: dev
22
+ Requires-Dist: build<2,>=1.2; extra == "dev"
23
+ Requires-Dist: ruff<1,>=0.11; extra == "dev"
24
+ Requires-Dist: twine<7,>=6; extra == "dev"
25
+
26
+ # git-epic
27
+
28
+ A Python CLI for our epic-based Git workflow, with no runtime Python dependencies.
29
+ Git discovers the `git-epic`
30
+ executable automatically, so commands use `git epic …`.
31
+
32
+ ## Install
33
+
34
+ Requires Python 3.11+, Git 2.31+, and [glab](https://gitlab.com/gitlab-org/cli).
35
+ Authenticate once with your GitLab host:
36
+
37
+ ```sh
38
+ glab auth login --hostname gitlab.wonderbeauties.com
39
+ ```
40
+
41
+ After the first PyPI release, install with [pipx](https://pipx.pypa.io/):
42
+
43
+ ```sh
44
+ pipx install git-epic
45
+ pipx ensurepath
46
+ git epic -h
47
+ ```
48
+
49
+ For local development before publication:
50
+
51
+ ```sh
52
+ pipx install .
53
+ pipx ensurepath
54
+ git epic -h
55
+ ```
56
+
57
+ If `ensurepath` changes your PATH, open a new terminal. Upgrade with
58
+ `pipx upgrade git-epic`. The package is not yet published; `pipx install .` works
59
+ from a local checkout today.
60
+
61
+ ## Commands
62
+
63
+ Use `git epic -h` for CLI help. Git reserves `git epic --help` for a system manual
64
+ page; this MVP does not install one. Direct invocation also supports
65
+ `git-epic --help`.
66
+
67
+ | Command | Behavior |
68
+ | --- | --- |
69
+ | `git epic init` | Write shared `.git-epic.json`; defaults to `production`, `origin`, and GitLab. |
70
+ | `git epic start <name>` | Fetch production; create and switch to local `epic/<name>`. |
71
+ | `git epic task start <name>` | From an epic, fetch and fast-forward remote epic changes if possible; create `task/<epic>/<name>`. |
72
+ | `git epic task submit` | Push committed task changes; create or return its parent epic MR. Publish the local parent epic if it is not yet on the remote. |
73
+ | `git epic sync` | From an epic, fetch and merge production. Push the result yourself or with `release`. |
74
+ | `git epic status` | Fetch; show current task/epic, labelled local/remote epic drift, related open MRs (including task MRs when on an epic), and their latest reported CI status. |
75
+ | `git epic release` | Push committed epic changes; create or return an MR targeting production. **Never merges or deploys.** |
76
+
77
+ GitLab calls PRs **merge requests (MRs)**. Submission commands also push new commits
78
+ when returning an existing MR; they do not create a duplicate.
79
+
80
+ Names are single segments: letters, numbers, dots, underscores, and hyphens, starting
81
+ with a letter or number. Git's branch-name restrictions also apply.
82
+
83
+ For repositories using a different production branch or remote:
84
+
85
+ ```sh
86
+ git epic init --production main --remote origin
87
+ git add .git-epic.json
88
+ git commit -m "Configure git-epic"
89
+ git push
90
+ ```
91
+
92
+ Commit this file on production before creating epics so all branches and teammates
93
+ inherit it. It contains no credentials. Existing configuration is never overwritten;
94
+ edit the file to change settings. An initial production commit must already exist on
95
+ the remote; `init` does not bootstrap an empty application repository.
96
+
97
+ ## Typical workflow
98
+
99
+ ```sh
100
+ git epic start checkout
101
+ git epic task start payment
102
+ # Implement and commit your task.
103
+ git epic task submit
104
+ # Review, CI, and merge the task MR in GitLab.
105
+ git switch epic/checkout
106
+ # Bring merged tasks into the local epic.
107
+ git fetch origin
108
+ git merge --ff-only origin/epic/checkout
109
+ git epic sync
110
+ git push origin epic/checkout
111
+ # Deploy OTF and obtain QA approval through the existing team process.
112
+ git epic release
113
+ # Review, required CI, and merge in GitLab; CI/CD deploys production.
114
+ ```
115
+
116
+ OTF deployment and QA approval are external to this CLI. Approval belongs to an
117
+ exact commit/build. If the candidate or production changes, update and revalidate
118
+ before merging. `release` refuses an epic that is behind current production, but
119
+ the hosting platform/CI must also enforce release checks at merge time. The CLI
120
+ does not record or verify QA approval.
121
+
122
+ ## Git behavior
123
+
124
+ - Branch and submission operations require a clean working tree and no unfinished
125
+ merge/rebase/cherry-pick/revert. `status` allows uncommitted changes.
126
+ - Fetch updates remote-tracking refs. No command force-pushes, resets, deletes a
127
+ branch, merges an MR, or deploys.
128
+ - `start` creates a local epic. Submission publishes branches with ordinary pushes;
129
+ non-fast-forward pushes are rejected rather than overwriting teammates' work.
130
+ - `task start` stops if local and remote epic histories have diverged. Reconcile
131
+ the epic using Git before retrying. `release` similarly requires your local epic
132
+ to include remote epic work before a push can succeed.
133
+ - If `sync` conflicts, resolve and commit, or use `git merge --abort`.
134
+ - An MR/API failure after a push leaves the branch published; retry the command.
135
+ - GitLab authentication is managed by `glab`, and Git authentication by Git.
136
+
137
+ ## Development and release
138
+
139
+ ```sh
140
+ python3 -m venv .venv
141
+ source .venv/bin/activate
142
+ python -m pip install -e '.[dev]'
143
+ ruff check .
144
+ ruff format --check .
145
+ python -m unittest discover -s tests -v
146
+ python -m build
147
+ python -m twine check --strict dist/*.whl dist/*.tar.gz
148
+ ```
149
+
150
+ Tests create temporary local Git repositories and a fake `glab`; they never create
151
+ real MRs or touch production. GitLab API behavior is delegated to the installed
152
+ `glab`. CI checks Python 3.11 and 3.14, builds a wheel and source distribution,
153
+ validates metadata with Twine, and smoke-tests the installed wheel via Git.
154
+
155
+ ## Publish to PyPI
156
+
157
+ ### Local publication helper
158
+
159
+ From a clean, committed checkout with a configured Git upstream, run:
160
+
161
+ ```sh
162
+ ./publish.sh
163
+ ```
164
+
165
+ The helper uses `uv` to prompt for a PEP 440 version (press Enter to keep the
166
+ current version for the first release), update the version and README release-tag
167
+ examples, run tests, clean and build distributions, validate with Twine, upload
168
+ to PyPI, and commit/push the release changes. Set `PYPI_API_TOKEN` or
169
+ `TWINE_PASSWORD` in your shell; otherwise Twine prompts for authentication.
170
+ It does not create a release tag or trigger the CI publication job.
171
+
172
+ ### GitLab CI publication
173
+
174
+ The distribution name is **`git-epic`**, import package is **`git_epic`**, and CLI
175
+ entry point is **`git-epic`**. Version is defined once in
176
+ `src/git_epic/__init__.py`.
177
+
178
+ 1. Create a PyPI account and an API token. For the first release, a token with
179
+ account scope is needed; after creating the project, use a project-scoped token.
180
+ 2. In GitLab CI/CD variables, add **`PYPI_API_TOKEN`** as a masked, protected
181
+ variable. Protect the release tag pattern (`v*`) so tagged jobs can access it.
182
+ 3. Update the version, then push a matching tag, for example `v0.1.0`.
183
+ 4. After verification and build jobs pass, manually run **`publish:pypi`**.
184
+
185
+ The job uploads only the validated wheel and source distribution from the build
186
+ job. A release tag must match the package version. PyPI does not allow reusing
187
+ an uploaded version: use a new version for subsequent releases.
188
+
189
+ An optional **`publish:testpypi`** job uses **`TESTPYPI_API_TOKEN`** and the separate
190
+ TestPyPI registry. TestPyPI accounts and tokens are independent of PyPI. A test
191
+ installation uses `pipx install --index-url https://test.pypi.org/simple/ git-epic`.
192
+
193
+ For a manual upload of locally built, validated distributions:
194
+
195
+ ```sh
196
+ export TWINE_USERNAME=__token__
197
+ # Set TWINE_PASSWORD to your PyPI API token in your shell.
198
+ python -m twine upload --non-interactive dist/*.whl dist/*.tar.gz
199
+ ```
200
+
201
+ Credentials are supplied by the shell or CI, never stored in project files. PyPI
202
+ Trusted Publishing currently supports GitLab.com, not this self-managed GitLab
203
+ host, so the release jobs use API tokens. Package-name availability is determined
204
+ by PyPI at upload time. A public PyPI release includes the source code even when
205
+ the GitLab repository is private.
@@ -0,0 +1,13 @@
1
+ git_epic/__init__.py,sha256=TaPmkb9usOUgMeLVOsyHpxV_jLvApDluZt-Wkf5ZnXM,55
2
+ git_epic/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ git_epic/cli.py,sha256=nZ5j_ekYdc8BWiSA1UO3GVQZKqkKwt6-ytwsMJzYDNk,2271
4
+ git_epic/config.py,sha256=ljEHqKn94levnqKT-j8Y-CHNm0IZBlCvi1INMa5VJBQ,1774
5
+ git_epic/git.py,sha256=3hHhwnT6wUyAbV3Q5xDRAncfivQzDElc1UJCV1lf0N8,2905
6
+ git_epic/gitlab.py,sha256=rPxoVzEMKawe3q-PsC3v2-igBQko2Sk5CEsZtLeEJ_s,3843
7
+ git_epic/process.py,sha256=cEfNjoNHbGEG7lZk3aJskeJ7QF-9kQ2S2qqX3Lc5Glo,949
8
+ git_epic/workflow.py,sha256=pG1XGPZftFWjcg2xIlWwGKjTpTOSdp9D7I9-qKFV5GM,6544
9
+ git_epic-0.1.0.dist-info/METADATA,sha256=HKnespcLSHQ9c8oYI2Pt2WTu1DaQ8x4PXXc4y7Dse-M,8488
10
+ git_epic-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ git_epic-0.1.0.dist-info/entry_points.txt,sha256=xIHqx8kk4Vsqv-GDrN4d_zKEhKyT3w8bFgDFTJVojfY,47
12
+ git_epic-0.1.0.dist-info/top_level.txt,sha256=32PQr4miBSJjSknOJlMf5aT-3NZ4Bo7g9-kSHmrOux8,9
13
+ git_epic-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-epic = git_epic.cli:main
@@ -0,0 +1 @@
1
+ git_epic