pymelos 0.1.3__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.
- pymelos/__init__.py +63 -0
- pymelos/__main__.py +6 -0
- pymelos/cli/__init__.py +5 -0
- pymelos/cli/__main__.py +6 -0
- pymelos/cli/app.py +527 -0
- pymelos/cli/commands/__init__.py +1 -0
- pymelos/cli/commands/init.py +151 -0
- pymelos/commands/__init__.py +84 -0
- pymelos/commands/add.py +77 -0
- pymelos/commands/base.py +108 -0
- pymelos/commands/bootstrap.py +154 -0
- pymelos/commands/changed.py +161 -0
- pymelos/commands/clean.py +142 -0
- pymelos/commands/exec.py +116 -0
- pymelos/commands/list.py +128 -0
- pymelos/commands/release.py +258 -0
- pymelos/commands/run.py +160 -0
- pymelos/compat.py +14 -0
- pymelos/config/__init__.py +47 -0
- pymelos/config/loader.py +132 -0
- pymelos/config/schema.py +236 -0
- pymelos/errors.py +139 -0
- pymelos/execution/__init__.py +32 -0
- pymelos/execution/parallel.py +249 -0
- pymelos/execution/results.py +172 -0
- pymelos/execution/runner.py +171 -0
- pymelos/filters/__init__.py +27 -0
- pymelos/filters/chain.py +101 -0
- pymelos/filters/ignore.py +60 -0
- pymelos/filters/scope.py +90 -0
- pymelos/filters/since.py +98 -0
- pymelos/git/__init__.py +69 -0
- pymelos/git/changes.py +153 -0
- pymelos/git/commits.py +174 -0
- pymelos/git/repo.py +210 -0
- pymelos/git/tags.py +242 -0
- pymelos/py.typed +0 -0
- pymelos/types.py +16 -0
- pymelos/uv/__init__.py +44 -0
- pymelos/uv/client.py +167 -0
- pymelos/uv/publish.py +162 -0
- pymelos/uv/sync.py +168 -0
- pymelos/versioning/__init__.py +57 -0
- pymelos/versioning/changelog.py +189 -0
- pymelos/versioning/conventional.py +216 -0
- pymelos/versioning/semver.py +249 -0
- pymelos/versioning/updater.py +146 -0
- pymelos/workspace/__init__.py +33 -0
- pymelos/workspace/discovery.py +138 -0
- pymelos/workspace/graph.py +238 -0
- pymelos/workspace/package.py +191 -0
- pymelos/workspace/workspace.py +218 -0
- pymelos-0.1.3.dist-info/METADATA +106 -0
- pymelos-0.1.3.dist-info/RECORD +57 -0
- pymelos-0.1.3.dist-info/WHEEL +4 -0
- pymelos-0.1.3.dist-info/entry_points.txt +2 -0
- pymelos-0.1.3.dist-info/licenses/LICENSE +21 -0
pymelos/filters/since.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Git-based package filtering (--since)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pymelos.workspace.package import Package
|
|
10
|
+
from pymelos.workspace.workspace import Workspace
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_changed_files(
|
|
14
|
+
root: Path,
|
|
15
|
+
since: str,
|
|
16
|
+
*,
|
|
17
|
+
include_untracked: bool = True,
|
|
18
|
+
) -> set[Path]:
|
|
19
|
+
"""Get files changed since a git reference.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
root: Repository root.
|
|
23
|
+
since: Git reference (branch, tag, commit).
|
|
24
|
+
include_untracked: Include untracked files.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Set of changed file paths (relative to root).
|
|
28
|
+
"""
|
|
29
|
+
from pymelos.git import get_changed_files_since
|
|
30
|
+
|
|
31
|
+
return get_changed_files_since(root, since, include_untracked=include_untracked)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_changed_packages(
|
|
35
|
+
workspace: Workspace,
|
|
36
|
+
since: str,
|
|
37
|
+
*,
|
|
38
|
+
include_dependents: bool = False,
|
|
39
|
+
) -> list[Package]:
|
|
40
|
+
"""Get packages that have changed since a git reference.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
workspace: Workspace instance.
|
|
44
|
+
since: Git reference.
|
|
45
|
+
include_dependents: Also include packages that depend on changed packages.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of changed packages.
|
|
49
|
+
"""
|
|
50
|
+
changed_files = get_changed_files(workspace.root, since)
|
|
51
|
+
|
|
52
|
+
# Map changed files to packages
|
|
53
|
+
changed_packages: list[Package] = []
|
|
54
|
+
|
|
55
|
+
for package in workspace.packages.values():
|
|
56
|
+
# Check if any changed file is within this package
|
|
57
|
+
for changed_file in changed_files:
|
|
58
|
+
abs_changed = workspace.root / changed_file
|
|
59
|
+
try:
|
|
60
|
+
abs_changed.relative_to(package.path)
|
|
61
|
+
if package not in changed_packages:
|
|
62
|
+
changed_packages.append(package)
|
|
63
|
+
break
|
|
64
|
+
except ValueError:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if include_dependents:
|
|
68
|
+
affected = workspace.get_affected_packages(changed_packages)
|
|
69
|
+
return affected
|
|
70
|
+
|
|
71
|
+
return changed_packages
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def filter_by_since(
|
|
75
|
+
packages: list[Package],
|
|
76
|
+
workspace: Workspace,
|
|
77
|
+
since: str | None,
|
|
78
|
+
*,
|
|
79
|
+
include_dependents: bool = False,
|
|
80
|
+
) -> list[Package]:
|
|
81
|
+
"""Filter packages to only those changed since a git reference.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
packages: List of packages to filter.
|
|
85
|
+
workspace: Workspace instance.
|
|
86
|
+
since: Git reference.
|
|
87
|
+
include_dependents: Also include packages that depend on changed packages.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Filtered list of packages.
|
|
91
|
+
"""
|
|
92
|
+
if not since:
|
|
93
|
+
return packages
|
|
94
|
+
|
|
95
|
+
changed = get_changed_packages(workspace, since, include_dependents=include_dependents)
|
|
96
|
+
changed_names = {p.name for p in changed}
|
|
97
|
+
|
|
98
|
+
return [p for p in packages if p.name in changed_names]
|
pymelos/git/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Git operations."""
|
|
2
|
+
|
|
3
|
+
from pymelos.git.changes import (
|
|
4
|
+
get_changed_files_since,
|
|
5
|
+
get_commits_since,
|
|
6
|
+
get_files_in_commit,
|
|
7
|
+
get_merge_base,
|
|
8
|
+
is_ancestor,
|
|
9
|
+
)
|
|
10
|
+
from pymelos.git.commits import (
|
|
11
|
+
Commit,
|
|
12
|
+
get_commit,
|
|
13
|
+
get_commits,
|
|
14
|
+
get_commits_affecting_path,
|
|
15
|
+
)
|
|
16
|
+
from pymelos.git.repo import (
|
|
17
|
+
get_current_branch,
|
|
18
|
+
get_current_commit,
|
|
19
|
+
get_default_branch,
|
|
20
|
+
get_repo_root,
|
|
21
|
+
is_clean,
|
|
22
|
+
is_git_repo,
|
|
23
|
+
run_git_command,
|
|
24
|
+
run_git_command_async,
|
|
25
|
+
)
|
|
26
|
+
from pymelos.git.tags import (
|
|
27
|
+
Tag,
|
|
28
|
+
create_tag,
|
|
29
|
+
delete_tag,
|
|
30
|
+
get_latest_package_tag,
|
|
31
|
+
get_latest_tag,
|
|
32
|
+
get_package_tags,
|
|
33
|
+
get_tags_for_commit,
|
|
34
|
+
list_tags,
|
|
35
|
+
parse_version_from_tag,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Repo
|
|
40
|
+
"is_git_repo",
|
|
41
|
+
"get_repo_root",
|
|
42
|
+
"run_git_command",
|
|
43
|
+
"run_git_command_async",
|
|
44
|
+
"get_current_branch",
|
|
45
|
+
"get_current_commit",
|
|
46
|
+
"is_clean",
|
|
47
|
+
"get_default_branch",
|
|
48
|
+
# Changes
|
|
49
|
+
"get_changed_files_since",
|
|
50
|
+
"get_files_in_commit",
|
|
51
|
+
"get_commits_since",
|
|
52
|
+
"is_ancestor",
|
|
53
|
+
"get_merge_base",
|
|
54
|
+
# Commits
|
|
55
|
+
"Commit",
|
|
56
|
+
"get_commits",
|
|
57
|
+
"get_commit",
|
|
58
|
+
"get_commits_affecting_path",
|
|
59
|
+
# Tags
|
|
60
|
+
"Tag",
|
|
61
|
+
"list_tags",
|
|
62
|
+
"get_latest_tag",
|
|
63
|
+
"get_tags_for_commit",
|
|
64
|
+
"create_tag",
|
|
65
|
+
"delete_tag",
|
|
66
|
+
"parse_version_from_tag",
|
|
67
|
+
"get_package_tags",
|
|
68
|
+
"get_latest_package_tag",
|
|
69
|
+
]
|
pymelos/git/changes.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Git change detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pymelos.git.repo import run_git_command
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_changed_files_since(
|
|
11
|
+
cwd: Path,
|
|
12
|
+
since: str,
|
|
13
|
+
*,
|
|
14
|
+
include_untracked: bool = True,
|
|
15
|
+
) -> set[Path]:
|
|
16
|
+
"""Get files changed since a git reference.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
cwd: Working directory (repository root).
|
|
20
|
+
since: Git reference (branch, tag, commit SHA).
|
|
21
|
+
include_untracked: Include untracked files.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Set of changed file paths (relative to cwd).
|
|
25
|
+
"""
|
|
26
|
+
changed: set[Path] = set()
|
|
27
|
+
|
|
28
|
+
# Get files changed between since ref and HEAD
|
|
29
|
+
result = run_git_command(
|
|
30
|
+
["diff", "--name-only", f"{since}...HEAD"],
|
|
31
|
+
cwd=cwd,
|
|
32
|
+
check=False,
|
|
33
|
+
)
|
|
34
|
+
if result.returncode == 0:
|
|
35
|
+
for line in result.stdout.strip().split("\n"):
|
|
36
|
+
if line:
|
|
37
|
+
changed.add(Path(line))
|
|
38
|
+
|
|
39
|
+
# Get staged changes
|
|
40
|
+
result = run_git_command(
|
|
41
|
+
["diff", "--name-only", "--cached"],
|
|
42
|
+
cwd=cwd,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
if result.returncode == 0:
|
|
46
|
+
for line in result.stdout.strip().split("\n"):
|
|
47
|
+
if line:
|
|
48
|
+
changed.add(Path(line))
|
|
49
|
+
|
|
50
|
+
# Get unstaged changes
|
|
51
|
+
result = run_git_command(
|
|
52
|
+
["diff", "--name-only"],
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode == 0:
|
|
57
|
+
for line in result.stdout.strip().split("\n"):
|
|
58
|
+
if line:
|
|
59
|
+
changed.add(Path(line))
|
|
60
|
+
|
|
61
|
+
# Get untracked files
|
|
62
|
+
if include_untracked:
|
|
63
|
+
result = run_git_command(
|
|
64
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
65
|
+
cwd=cwd,
|
|
66
|
+
check=False,
|
|
67
|
+
)
|
|
68
|
+
if result.returncode == 0:
|
|
69
|
+
for line in result.stdout.strip().split("\n"):
|
|
70
|
+
if line:
|
|
71
|
+
changed.add(Path(line))
|
|
72
|
+
|
|
73
|
+
return changed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_files_in_commit(cwd: Path, commit: str) -> set[Path]:
|
|
77
|
+
"""Get files changed in a specific commit.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
cwd: Working directory.
|
|
81
|
+
commit: Commit SHA.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Set of file paths changed in the commit.
|
|
85
|
+
"""
|
|
86
|
+
result = run_git_command(
|
|
87
|
+
["diff-tree", "--no-commit-id", "--name-only", "-r", commit],
|
|
88
|
+
cwd=cwd,
|
|
89
|
+
)
|
|
90
|
+
files: set[Path] = set()
|
|
91
|
+
for line in result.stdout.strip().split("\n"):
|
|
92
|
+
if line:
|
|
93
|
+
files.add(Path(line))
|
|
94
|
+
return files
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_commits_since(
|
|
98
|
+
cwd: Path,
|
|
99
|
+
since: str,
|
|
100
|
+
*,
|
|
101
|
+
path: Path | None = None,
|
|
102
|
+
) -> list[str]:
|
|
103
|
+
"""Get commit SHAs since a reference.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
cwd: Working directory.
|
|
107
|
+
since: Git reference.
|
|
108
|
+
path: Optional path to filter commits.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of commit SHAs (newest first).
|
|
112
|
+
"""
|
|
113
|
+
args = ["log", "--format=%H", f"{since}..HEAD"]
|
|
114
|
+
if path:
|
|
115
|
+
args.extend(["--", str(path)])
|
|
116
|
+
|
|
117
|
+
result = run_git_command(args, cwd=cwd)
|
|
118
|
+
commits = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
|
119
|
+
return commits
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_ancestor(cwd: Path, commit: str, ancestor: str) -> bool:
|
|
123
|
+
"""Check if ancestor is an ancestor of commit.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
cwd: Working directory.
|
|
127
|
+
commit: Commit to check.
|
|
128
|
+
ancestor: Potential ancestor.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if ancestor is an ancestor of commit.
|
|
132
|
+
"""
|
|
133
|
+
result = run_git_command(
|
|
134
|
+
["merge-base", "--is-ancestor", ancestor, commit],
|
|
135
|
+
cwd=cwd,
|
|
136
|
+
check=False,
|
|
137
|
+
)
|
|
138
|
+
return result.returncode == 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_merge_base(cwd: Path, ref1: str, ref2: str) -> str:
|
|
142
|
+
"""Get the merge base between two refs.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
cwd: Working directory.
|
|
146
|
+
ref1: First reference.
|
|
147
|
+
ref2: Second reference.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Merge base commit SHA.
|
|
151
|
+
"""
|
|
152
|
+
result = run_git_command(["merge-base", ref1, ref2], cwd=cwd)
|
|
153
|
+
return result.stdout.strip()
|
pymelos/git/commits.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Git commit parsing and analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pymelos.git.repo import run_git_command
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Commit:
|
|
13
|
+
"""Represents a git commit.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
sha: Full commit SHA.
|
|
17
|
+
short_sha: Abbreviated commit SHA.
|
|
18
|
+
message: Full commit message.
|
|
19
|
+
subject: First line of commit message.
|
|
20
|
+
body: Commit message body (after first line).
|
|
21
|
+
author_name: Commit author name.
|
|
22
|
+
author_email: Commit author email.
|
|
23
|
+
timestamp: Unix timestamp of commit.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
sha: str
|
|
27
|
+
short_sha: str
|
|
28
|
+
message: str
|
|
29
|
+
author_name: str
|
|
30
|
+
author_email: str
|
|
31
|
+
timestamp: int
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def subject(self) -> str:
|
|
35
|
+
"""First line of commit message."""
|
|
36
|
+
return self.message.split("\n", 1)[0]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def body(self) -> str | None:
|
|
40
|
+
"""Commit message body (after first line)."""
|
|
41
|
+
parts = self.message.split("\n", 1)
|
|
42
|
+
if len(parts) > 1:
|
|
43
|
+
return parts[1].strip()
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Format for git log output (fields separated by special delimiter)
|
|
48
|
+
# Uses record separator (%x1e) at end to handle bodies with newlines
|
|
49
|
+
LOG_FORMAT = "%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%ct%x1e"
|
|
50
|
+
FIELD_SEPARATOR = "\x00"
|
|
51
|
+
COMMIT_SEPARATOR = "\x1e" # Record separator
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_commit_line(line: str) -> Commit | None:
|
|
55
|
+
"""Parse a single commit from git log output.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
line: Output line from git log with LOG_FORMAT.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Parsed Commit or None if parsing fails.
|
|
62
|
+
"""
|
|
63
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
64
|
+
if len(parts) < 7:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
sha, short_sha, subject, body, author_name, author_email, timestamp_str = parts[:7]
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
timestamp = int(timestamp_str)
|
|
71
|
+
except ValueError:
|
|
72
|
+
timestamp = 0
|
|
73
|
+
|
|
74
|
+
# Combine subject and body for full message
|
|
75
|
+
message = subject
|
|
76
|
+
if body.strip():
|
|
77
|
+
message = f"{subject}\n\n{body}"
|
|
78
|
+
|
|
79
|
+
return Commit(
|
|
80
|
+
sha=sha,
|
|
81
|
+
short_sha=short_sha,
|
|
82
|
+
message=message,
|
|
83
|
+
author_name=author_name,
|
|
84
|
+
author_email=author_email,
|
|
85
|
+
timestamp=timestamp,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_commits(
|
|
90
|
+
cwd: Path,
|
|
91
|
+
since: str | None = None,
|
|
92
|
+
until: str | None = None,
|
|
93
|
+
path: Path | None = None,
|
|
94
|
+
limit: int | None = None,
|
|
95
|
+
) -> list[Commit]:
|
|
96
|
+
"""Get commits from the repository.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
cwd: Working directory.
|
|
100
|
+
since: Start reference (exclusive).
|
|
101
|
+
until: End reference (inclusive). Defaults to HEAD.
|
|
102
|
+
path: Filter to commits affecting this path.
|
|
103
|
+
limit: Maximum number of commits.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
List of commits (newest first).
|
|
107
|
+
"""
|
|
108
|
+
args = ["log", f"--format={LOG_FORMAT}"]
|
|
109
|
+
|
|
110
|
+
if limit:
|
|
111
|
+
args.append(f"-n{limit}")
|
|
112
|
+
|
|
113
|
+
if since and until:
|
|
114
|
+
args.append(f"{since}..{until}")
|
|
115
|
+
elif since:
|
|
116
|
+
args.append(f"{since}..HEAD")
|
|
117
|
+
elif until:
|
|
118
|
+
args.append(until)
|
|
119
|
+
|
|
120
|
+
if path:
|
|
121
|
+
args.extend(["--", str(path)])
|
|
122
|
+
|
|
123
|
+
result = run_git_command(args, cwd=cwd)
|
|
124
|
+
|
|
125
|
+
commits: list[Commit] = []
|
|
126
|
+
# Split by record separator to handle bodies with newlines
|
|
127
|
+
for record in result.stdout.split(COMMIT_SEPARATOR):
|
|
128
|
+
record = record.strip()
|
|
129
|
+
if record:
|
|
130
|
+
commit = parse_commit_line(record)
|
|
131
|
+
if commit:
|
|
132
|
+
commits.append(commit)
|
|
133
|
+
|
|
134
|
+
return commits
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_commit(cwd: Path, ref: str) -> Commit | None:
|
|
138
|
+
"""Get a single commit by reference.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
cwd: Working directory.
|
|
142
|
+
ref: Git reference (SHA, branch, tag).
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Commit or None if not found.
|
|
146
|
+
"""
|
|
147
|
+
args = ["log", "-1", f"--format={LOG_FORMAT}", ref]
|
|
148
|
+
|
|
149
|
+
result = run_git_command(args, cwd=cwd, check=False)
|
|
150
|
+
if result.returncode != 0:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
line = result.stdout.strip()
|
|
154
|
+
if line:
|
|
155
|
+
return parse_commit_line(line)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_commits_affecting_path(
|
|
160
|
+
cwd: Path,
|
|
161
|
+
path: Path,
|
|
162
|
+
since: str | None = None,
|
|
163
|
+
) -> list[Commit]:
|
|
164
|
+
"""Get commits that affected a specific path.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
cwd: Working directory.
|
|
168
|
+
path: Path to filter by.
|
|
169
|
+
since: Start reference (exclusive).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of commits affecting the path.
|
|
173
|
+
"""
|
|
174
|
+
return get_commits(cwd, since=since, path=path)
|
pymelos/git/repo.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Git repository abstraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pymelos.errors import GitError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_git_repo(path: Path) -> bool:
|
|
13
|
+
"""Check if path is inside a git repository.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
path: Path to check.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
True if path is inside a git repository.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
["git", "rev-parse", "--git-dir"],
|
|
24
|
+
cwd=path,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
return result.returncode == 0
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_repo_root(path: Path) -> Path:
|
|
35
|
+
"""Get the root directory of the git repository.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
path: Path inside the repository.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Path to repository root.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
GitError: If not inside a git repository.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
49
|
+
cwd=path,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
check=True,
|
|
53
|
+
)
|
|
54
|
+
return Path(result.stdout.strip())
|
|
55
|
+
except subprocess.CalledProcessError as e:
|
|
56
|
+
raise GitError(
|
|
57
|
+
"Not inside a git repository",
|
|
58
|
+
command="git rev-parse --show-toplevel",
|
|
59
|
+
) from e
|
|
60
|
+
except FileNotFoundError as e:
|
|
61
|
+
raise GitError("Git is not installed") from e
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_git_command(
|
|
65
|
+
args: list[str],
|
|
66
|
+
cwd: Path | None = None,
|
|
67
|
+
*,
|
|
68
|
+
check: bool = True,
|
|
69
|
+
) -> subprocess.CompletedProcess[str]:
|
|
70
|
+
"""Run a git command synchronously.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
args: Git command arguments (without 'git').
|
|
74
|
+
cwd: Working directory.
|
|
75
|
+
check: Raise on non-zero exit code.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Completed process result.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
GitError: If command fails and check is True.
|
|
82
|
+
"""
|
|
83
|
+
cmd = ["git"] + args
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
cwd=cwd,
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
if check and result.returncode != 0:
|
|
94
|
+
raise GitError(
|
|
95
|
+
result.stderr.strip() or f"Command failed with exit code {result.returncode}",
|
|
96
|
+
command=" ".join(cmd),
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
99
|
+
except FileNotFoundError as e:
|
|
100
|
+
raise GitError("Git is not installed") from e
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def run_git_command_async(
|
|
104
|
+
args: list[str],
|
|
105
|
+
cwd: Path | None = None,
|
|
106
|
+
*,
|
|
107
|
+
check: bool = True,
|
|
108
|
+
) -> tuple[int, str, str]:
|
|
109
|
+
"""Run a git command asynchronously.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
args: Git command arguments (without 'git').
|
|
113
|
+
cwd: Working directory.
|
|
114
|
+
check: Raise on non-zero exit code.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Tuple of (exit_code, stdout, stderr).
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
GitError: If command fails and check is True.
|
|
121
|
+
"""
|
|
122
|
+
cmd = ["git"] + args
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
process = await asyncio.create_subprocess_exec(
|
|
126
|
+
*cmd,
|
|
127
|
+
cwd=cwd,
|
|
128
|
+
stdout=asyncio.subprocess.PIPE,
|
|
129
|
+
stderr=asyncio.subprocess.PIPE,
|
|
130
|
+
)
|
|
131
|
+
stdout_bytes, stderr_bytes = await process.communicate()
|
|
132
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
133
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
134
|
+
|
|
135
|
+
if check and process.returncode != 0:
|
|
136
|
+
raise GitError(
|
|
137
|
+
stderr.strip() or f"Command failed with exit code {process.returncode}",
|
|
138
|
+
command=" ".join(cmd),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return process.returncode or 0, stdout, stderr
|
|
142
|
+
except FileNotFoundError as e:
|
|
143
|
+
raise GitError("Git is not installed") from e
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_current_branch(cwd: Path | None = None) -> str:
|
|
147
|
+
"""Get the current git branch name.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
cwd: Working directory.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Current branch name.
|
|
154
|
+
"""
|
|
155
|
+
result = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
|
156
|
+
return result.stdout.strip()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_current_commit(cwd: Path | None = None) -> str:
|
|
160
|
+
"""Get the current commit SHA.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
cwd: Working directory.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Current commit SHA.
|
|
167
|
+
"""
|
|
168
|
+
result = run_git_command(["rev-parse", "HEAD"], cwd=cwd)
|
|
169
|
+
return result.stdout.strip()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_clean(cwd: Path | None = None) -> bool:
|
|
173
|
+
"""Check if the working directory is clean (no uncommitted changes).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
cwd: Working directory.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
True if working directory is clean.
|
|
180
|
+
"""
|
|
181
|
+
result = run_git_command(["status", "--porcelain"], cwd=cwd, check=False)
|
|
182
|
+
return not result.stdout.strip()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_default_branch(cwd: Path | None = None) -> str:
|
|
186
|
+
"""Get the default branch name (main or master).
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
cwd: Working directory.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Default branch name.
|
|
193
|
+
"""
|
|
194
|
+
# Try to get from remote
|
|
195
|
+
result = run_git_command(
|
|
196
|
+
["symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
197
|
+
cwd=cwd,
|
|
198
|
+
check=False,
|
|
199
|
+
)
|
|
200
|
+
if result.returncode == 0:
|
|
201
|
+
# refs/remotes/origin/main -> main
|
|
202
|
+
return result.stdout.strip().split("/")[-1]
|
|
203
|
+
|
|
204
|
+
# Check if main exists
|
|
205
|
+
result = run_git_command(["branch", "--list", "main"], cwd=cwd, check=False)
|
|
206
|
+
if result.stdout.strip():
|
|
207
|
+
return "main"
|
|
208
|
+
|
|
209
|
+
# Default to master
|
|
210
|
+
return "master"
|