feat(branch-gc): add --merged-into, --sizes and --min-age
This commit is contained in:
4 files changed
+276
-22
No files matched your search
@@ -6,6 +6,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ class Item:
|
|||||||
images: list[str] = field(default_factory=list)
|
images: list[str] = field(default_factory=list)
|
||||||
state: str = ""
|
state: str = ""
|
||||||
merged_by: str | None = None
|
merged_by: str | None = None
|
||||||
|
merged_into: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
skip: str | None = None
|
skip: str | None = None
|
||||||
actions: list[str] = field(default_factory=list)
|
actions: list[str] = field(default_factory=list)
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
@@ -88,6 +91,9 @@ class Repo:
|
|||||||
forge: ForgeFacts
|
forge: ForgeFacts
|
||||||
containers: list[Container] | None
|
containers: list[Container] | None
|
||||||
host_ns: bool
|
host_ns: bool
|
||||||
|
lines: dict[str, tuple[str, set[str]]]
|
||||||
|
sizes: bool
|
||||||
|
min_age: float | None
|
||||||
warnings: list[str] = field(default_factory=list)
|
warnings: list[str] = field(default_factory=list)
|
||||||
items: list[Item] = field(default_factory=list)
|
items: list[Item] = field(default_factory=list)
|
||||||
|
|
||||||
@@ -234,11 +240,51 @@ def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _in_line(repo: Repo, sha: str) -> str | None:
|
||||||
|
for line, (tip, first_parent) in repo.lines.items():
|
||||||
|
if sha == tip:
|
||||||
|
continue
|
||||||
|
if sha in first_parent or _is_ancestor(sha, line, repo.repo):
|
||||||
|
return line
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _merged_by(repo: Repo, sha: str) -> str | None:
|
def _merged_by(repo: Repo, sha: str) -> str | None:
|
||||||
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
|
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
|
||||||
return "ancestor"
|
return "ancestor"
|
||||||
number = repo.forge.merged_heads.get(sha)
|
number = repo.forge.merged_heads.get(sha)
|
||||||
return f"pr#{number}" if number else None
|
if number:
|
||||||
|
return f"pr#{number}"
|
||||||
|
line = _in_line(repo, sha)
|
||||||
|
return f"line:{line}" if line else None
|
||||||
|
|
||||||
|
|
||||||
|
def _du_bytes(path: str) -> int | None:
|
||||||
|
out = subprocess.run(
|
||||||
|
["du", "-sk", path], check=False, capture_output=True, text=True
|
||||||
|
).stdout.split()
|
||||||
|
return int(out[0]) * 1024 if out and out[0].isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _age_hours(repo: Repo, shas: list[str], tree: Worktree | None) -> float:
|
||||||
|
stamps = [
|
||||||
|
int(run(["log", "-1", "--format=%ct", sha], cwd=repo.repo)) for sha in shas
|
||||||
|
]
|
||||||
|
if tree and os.path.isfile(os.path.join(tree.path, ".git")):
|
||||||
|
with open(os.path.join(tree.path, ".git"), encoding="utf-8") as fh:
|
||||||
|
index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index")
|
||||||
|
if os.path.isfile(index):
|
||||||
|
stamps.append(int(os.path.getmtime(index)))
|
||||||
|
return (time.time() - max(stamps)) / 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _human(size: int) -> str:
|
||||||
|
value = float(size)
|
||||||
|
for unit in ("K", "M", "G"):
|
||||||
|
value /= 1024
|
||||||
|
if value < 1024:
|
||||||
|
return f"{value:.1f}{unit}"
|
||||||
|
return f"{value / 1024:.1f}T"
|
||||||
|
|
||||||
|
|
||||||
def _skip(
|
def _skip(
|
||||||
@@ -331,9 +377,20 @@ def _build(
|
|||||||
(verdict for verdict in verdicts.values() if verdict != "ancestor"),
|
(verdict for verdict in verdicts.values() if verdict != "ancestor"),
|
||||||
"ancestor",
|
"ancestor",
|
||||||
)
|
)
|
||||||
|
item.merged_into = (
|
||||||
|
item.merged_by.removeprefix("line:")
|
||||||
|
if item.merged_by.startswith("line:")
|
||||||
|
else repo.default
|
||||||
|
)
|
||||||
item.reason = f"merged by {item.merged_by}"
|
item.reason = f"merged by {item.merged_by}"
|
||||||
|
|
||||||
|
if repo.sizes and checkout and not tree.main:
|
||||||
|
item.size_bytes = _du_bytes(tree.path)
|
||||||
item.skip = _skip(repo, item, tree, checkout, cwds)
|
item.skip = _skip(repo, item, tree, checkout, cwds)
|
||||||
|
if item.skip is None and repo.min_age is not None and verdicts:
|
||||||
|
age = _age_hours(repo, list(verdicts), tree if checkout else None)
|
||||||
|
if age < repo.min_age:
|
||||||
|
item.skip = f"younger than {repo.min_age:g} h"
|
||||||
for project in item.compose_projects:
|
for project in item.compose_projects:
|
||||||
item.actions += [f"compose-down:{project}", f"images:{project}"]
|
item.actions += [f"compose-down:{project}", f"images:{project}"]
|
||||||
if tree:
|
if tree:
|
||||||
@@ -386,9 +443,51 @@ def _stale(repo_root: str) -> list[Item]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _lines(
|
||||||
|
repo_root: str, refs: list[str], remotes: list[str], warnings: list[str]
|
||||||
|
) -> tuple[dict[str, tuple[str, set[str]]], set[str]]:
|
||||||
|
lines: dict[str, tuple[str, set[str]]] = {}
|
||||||
|
names: set[str] = set()
|
||||||
|
for ref in refs:
|
||||||
|
try:
|
||||||
|
tip = run(
|
||||||
|
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=repo_root
|
||||||
|
)
|
||||||
|
except GitRunError:
|
||||||
|
warnings.append(f"merged-into ref {ref} not found")
|
||||||
|
continue
|
||||||
|
first_parent = set(
|
||||||
|
run(["rev-list", "--first-parent", ref], cwd=repo_root).splitlines()
|
||||||
|
)
|
||||||
|
lines[ref] = (tip, first_parent)
|
||||||
|
owner = next((r for r in remotes if ref.startswith(r + "/")), None)
|
||||||
|
names.add(ref[len(owner) + 1 :] if owner else ref)
|
||||||
|
return lines, names
|
||||||
|
|
||||||
|
|
||||||
def plan_repo(
|
def plan_repo(
|
||||||
repo_root: str, containers: list[Container] | None, host_ns: bool
|
repo_root: str,
|
||||||
|
containers: list[Container] | None,
|
||||||
|
host_ns: bool,
|
||||||
|
*,
|
||||||
|
merged_into: list[str],
|
||||||
|
sizes: bool,
|
||||||
|
min_age: float | None,
|
||||||
) -> Repo:
|
) -> Repo:
|
||||||
|
"""
|
||||||
|
Scan one repository and classify every branch and worktree.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_root: main worktree root of the repository.
|
||||||
|
containers: compose containers on the host; None when docker is unavailable.
|
||||||
|
host_ns: whether this process runs in the host PID namespace.
|
||||||
|
merged_into: integration lines (refs) that also count as merged; each is protected.
|
||||||
|
sizes: measure each worktree's disk usage into size_bytes.
|
||||||
|
min_age: skip items whose newest commit or worktree index is younger (hours).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The repository with its classified items.
|
||||||
|
"""
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
try:
|
try:
|
||||||
# Auto-maintenance runs worktree-prune, which deletes the metadata of
|
# Auto-maintenance runs worktree-prune, which deletes the metadata of
|
||||||
@@ -422,18 +521,28 @@ def plan_repo(
|
|||||||
)
|
)
|
||||||
if forge.unavailable:
|
if forge.unavailable:
|
||||||
warnings.append(f"forge unavailable: {forge.unavailable}")
|
warnings.append(f"forge unavailable: {forge.unavailable}")
|
||||||
|
lines, line_names = _lines(repo_root, merged_into, remotes, warnings)
|
||||||
|
|
||||||
repo = Repo(
|
repo = Repo(
|
||||||
repo=repo_root,
|
repo=repo_root,
|
||||||
default=default,
|
default=default,
|
||||||
remotes=remotes,
|
remotes=remotes,
|
||||||
protected={default, *ALWAYS_PROTECTED, *head_names, *forge.protected},
|
protected={
|
||||||
|
default,
|
||||||
|
*ALWAYS_PROTECTED,
|
||||||
|
*head_names,
|
||||||
|
*forge.protected,
|
||||||
|
*line_names,
|
||||||
|
},
|
||||||
first_parent=set(
|
first_parent=set(
|
||||||
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
|
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
|
||||||
),
|
),
|
||||||
forge=forge,
|
forge=forge,
|
||||||
containers=containers,
|
containers=containers,
|
||||||
host_ns=host_ns,
|
host_ns=host_ns,
|
||||||
|
lines=lines,
|
||||||
|
sizes=sizes,
|
||||||
|
min_age=min_age,
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
)
|
)
|
||||||
repo.items = _items(repo)
|
repo.items = _items(repo)
|
||||||
@@ -546,6 +655,8 @@ def _line(item: Item) -> str:
|
|||||||
parts.append(item.skip)
|
parts.append(item.skip)
|
||||||
if item.worktree and item.state != "stale":
|
if item.worktree and item.state != "stale":
|
||||||
parts.append(f"worktree {item.worktree}")
|
parts.append(f"worktree {item.worktree}")
|
||||||
|
if item.size_bytes is not None:
|
||||||
|
parts.append(f"size {_human(item.size_bytes)}")
|
||||||
if item.compose_projects:
|
if item.compose_projects:
|
||||||
parts.append("stack " + ", ".join(item.compose_projects))
|
parts.append("stack " + ", ".join(item.compose_projects))
|
||||||
if item.remotes:
|
if item.remotes:
|
||||||
@@ -566,6 +677,7 @@ def _render(
|
|||||||
{
|
{
|
||||||
"repo": repo.repo,
|
"repo": repo.repo,
|
||||||
"default": repo.default,
|
"default": repo.default,
|
||||||
|
"merged_into": list(repo.lines),
|
||||||
"warnings": repo.warnings,
|
"warnings": repo.warnings,
|
||||||
"items": [asdict(i) for i in repo.items if chosen(i)],
|
"items": [asdict(i) for i in repo.items if chosen(i)],
|
||||||
}
|
}
|
||||||
@@ -591,11 +703,14 @@ def _render(
|
|||||||
|
|
||||||
|
|
||||||
def _prepare(
|
def _prepare(
|
||||||
repo_roots: list[str], include: list[str], exclude: list[str]
|
repo_roots: list[str],
|
||||||
|
include: list[str],
|
||||||
|
exclude: list[str],
|
||||||
|
options: dict,
|
||||||
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
|
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
|
||||||
host_ns = host_pid_namespace()
|
host_ns = host_pid_namespace()
|
||||||
containers = list_compose_containers()
|
containers = list_compose_containers()
|
||||||
repos = [plan_repo(root, containers, host_ns) for root in repo_roots]
|
repos = [plan_repo(root, containers, host_ns, **options) for root in repo_roots]
|
||||||
unknown = [
|
unknown = [
|
||||||
name
|
name
|
||||||
for name in include
|
for name in include
|
||||||
@@ -633,6 +748,9 @@ def branch_gc(
|
|||||||
as_json: bool,
|
as_json: bool,
|
||||||
include: list[str],
|
include: list[str],
|
||||||
exclude: list[str],
|
exclude: list[str],
|
||||||
|
merged_into: list[str],
|
||||||
|
sizes: bool,
|
||||||
|
min_age: float | None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Plan, and with apply=True execute, the cleanup of merged and clean branches.
|
Plan, and with apply=True execute, the cleanup of merged and clean branches.
|
||||||
@@ -643,6 +761,9 @@ def branch_gc(
|
|||||||
as_json: print the plan as JSON instead of text lines.
|
as_json: print the plan as JSON instead of text lines.
|
||||||
include: only items whose branch or worktree path matches one of these.
|
include: only items whose branch or worktree path matches one of these.
|
||||||
exclude: drop items whose branch or worktree path matches one of these.
|
exclude: drop items whose branch or worktree path matches one of these.
|
||||||
|
merged_into: integration lines (refs) that also count as merged.
|
||||||
|
sizes: measure each worktree's disk usage into the plan.
|
||||||
|
min_age: skip items younger than this many hours.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
0 on success, 1 when an applied item failed, 2 when refused.
|
0 on success, 1 when an applied item failed, 2 when refused.
|
||||||
@@ -650,7 +771,8 @@ def branch_gc(
|
|||||||
if apply and not host_pid_namespace():
|
if apply and not host_pid_namespace():
|
||||||
print(REFUSAL, file=sys.stderr)
|
print(REFUSAL, file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
prepared = _prepare(repo_roots, include, exclude)
|
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
|
||||||
|
prepared = _prepare(repo_roots, include, exclude, options)
|
||||||
if prepared is None:
|
if prepared is None:
|
||||||
return 2
|
return 2
|
||||||
repos, host_ns, chosen = prepared
|
repos, host_ns, chosen = prepared
|
||||||
@@ -669,7 +791,13 @@ def branch_gc(
|
|||||||
|
|
||||||
|
|
||||||
def branch_purge(
|
def branch_purge(
|
||||||
repo_roots: list[str], *, include: list[str], exclude: list[str]
|
repo_roots: list[str],
|
||||||
|
*,
|
||||||
|
include: list[str],
|
||||||
|
exclude: list[str],
|
||||||
|
merged_into: list[str],
|
||||||
|
sizes: bool,
|
||||||
|
min_age: float | None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Delete unmerged or dirty items after a y/N confirmation per item.
|
Delete unmerged or dirty items after a y/N confirmation per item.
|
||||||
@@ -678,6 +806,9 @@ def branch_purge(
|
|||||||
repo_roots: main worktree roots of the repositories to scan.
|
repo_roots: main worktree roots of the repositories to scan.
|
||||||
include: only items whose branch or worktree path matches one of these.
|
include: only items whose branch or worktree path matches one of these.
|
||||||
exclude: drop items whose branch or worktree path matches one of these.
|
exclude: drop items whose branch or worktree path matches one of these.
|
||||||
|
merged_into: integration lines (refs) that also count as merged.
|
||||||
|
sizes: measure each worktree's disk usage into the prompt line.
|
||||||
|
min_age: skip items younger than this many hours.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
0 on success, 1 when a confirmed item failed, 2 when refused.
|
0 on success, 1 when a confirmed item failed, 2 when refused.
|
||||||
@@ -685,7 +816,8 @@ def branch_purge(
|
|||||||
if not host_pid_namespace():
|
if not host_pid_namespace():
|
||||||
print(REFUSAL, file=sys.stderr)
|
print(REFUSAL, file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
prepared = _prepare(repo_roots, include, exclude)
|
options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
|
||||||
|
prepared = _prepare(repo_roots, include, exclude, options)
|
||||||
if prepared is None:
|
if prepared is None:
|
||||||
return 2
|
return 2
|
||||||
repos, _, chosen = prepared
|
repos, _, chosen = prepared
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
|||||||
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
|
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
|
||||||
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
|
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
|
||||||
- pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
|
- pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
|
||||||
|
[--merged-into REF]... [--sizes] [--min-age HOURS]
|
||||||
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
|
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
|
||||||
|
[--merged-into REF]... [--sizes] [--min-age HOURS]
|
||||||
"""
|
"""
|
||||||
if args.subcommand in ("gc", "purge"):
|
if args.subcommand in ("gc", "purge"):
|
||||||
try:
|
try:
|
||||||
@@ -40,16 +42,17 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
|||||||
except GitBaseError as exc:
|
except GitBaseError as exc:
|
||||||
print(f"Not inside a git repository: {exc}", file=sys.stderr)
|
print(f"Not inside a git repository: {exc}", file=sys.stderr)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
options = {
|
||||||
|
"include": args.branch,
|
||||||
|
"exclude": args.exclude,
|
||||||
|
"merged_into": args.merged_into,
|
||||||
|
"sizes": args.sizes,
|
||||||
|
"min_age": args.min_age,
|
||||||
|
}
|
||||||
if args.subcommand == "gc":
|
if args.subcommand == "gc":
|
||||||
code = branch_gc(
|
code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
|
||||||
roots,
|
|
||||||
apply=args.apply,
|
|
||||||
as_json=args.json,
|
|
||||||
include=args.branch,
|
|
||||||
exclude=args.exclude,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
code = branch_purge(roots, include=args.branch, exclude=args.exclude)
|
code = branch_purge(roots, **options)
|
||||||
sys.exit(code)
|
sys.exit(code)
|
||||||
|
|
||||||
if args.subcommand == "open":
|
if args.subcommand == "open":
|
||||||
|
|||||||
@@ -140,3 +140,25 @@ def add_branch_subparsers(
|
|||||||
metavar="NAME",
|
metavar="NAME",
|
||||||
help="Skip this branch (or worktree path); repeatable",
|
help="Skip this branch (or worktree path); repeatable",
|
||||||
)
|
)
|
||||||
|
sub.add_argument(
|
||||||
|
"--merged-into",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="REF",
|
||||||
|
help=(
|
||||||
|
"Also count an item as merged when its commits are in REF (an "
|
||||||
|
"integration line); REF is protected and a checkout at its tip "
|
||||||
|
"counts as fresh; repeatable"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sub.add_argument(
|
||||||
|
"--sizes",
|
||||||
|
action="store_true",
|
||||||
|
help="Measure each worktree's disk usage (du) into the plan",
|
||||||
|
)
|
||||||
|
sub.add_argument(
|
||||||
|
"--min-age",
|
||||||
|
type=float,
|
||||||
|
metavar="HOURS",
|
||||||
|
help="Skip items whose newest commit or worktree index is younger",
|
||||||
|
)
|
||||||
@@ -98,12 +98,40 @@ class TestBranchGc(unittest.TestCase):
|
|||||||
def _lock(self, reason: str) -> None:
|
def _lock(self, reason: str) -> None:
|
||||||
git(self.repo, "worktree", "lock", "--reason", reason, self.locked_wt)
|
git(self.repo, "worktree", "lock", "--reason", reason, self.locked_wt)
|
||||||
|
|
||||||
def _plan(self, host_ns: bool, cwds: dict[int, str]) -> gc.Repo:
|
def _plan(
|
||||||
|
self,
|
||||||
|
host_ns: bool,
|
||||||
|
cwds: dict[int, str],
|
||||||
|
merged_into: list[str] | None = None,
|
||||||
|
sizes: bool = False,
|
||||||
|
min_age: float | None = None,
|
||||||
|
) -> gc.Repo:
|
||||||
with (
|
with (
|
||||||
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
|
patch.object(gc, "fetch_forge_facts", return_value=self.forge),
|
||||||
patch.object(gc, "_process_cwds", return_value=cwds),
|
patch.object(gc, "_process_cwds", return_value=cwds),
|
||||||
):
|
):
|
||||||
return gc.plan_repo(self.repo, [], host_ns)
|
return gc.plan_repo(
|
||||||
|
self.repo,
|
||||||
|
[],
|
||||||
|
host_ns,
|
||||||
|
merged_into=merged_into or [],
|
||||||
|
sizes=sizes,
|
||||||
|
min_age=min_age,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _integration_line(self) -> str:
|
||||||
|
git(self.repo, "checkout", "-b", "line", "main")
|
||||||
|
self._commit("line-base")
|
||||||
|
git(self.repo, "checkout", "-b", "feat", "line")
|
||||||
|
self._commit("feat")
|
||||||
|
git(self.repo, "checkout", "line")
|
||||||
|
git(self.repo, "merge", "--no-ff", "-m", "merge feat", "feat")
|
||||||
|
git(self.repo, "push", "origin", "line", "feat")
|
||||||
|
git(self.repo, "branch", "start", "line")
|
||||||
|
git(self.repo, "checkout", "main")
|
||||||
|
feat_wt = os.path.join(self.root, "wt-feat")
|
||||||
|
git(self.repo, "worktree", "add", feat_wt, "feat")
|
||||||
|
return feat_wt
|
||||||
|
|
||||||
def _items(self, plan: gc.Repo) -> dict[str, gc.Item]:
|
def _items(self, plan: gc.Repo) -> dict[str, gc.Item]:
|
||||||
return {item.branch: item for item in plan.items if item.branch}
|
return {item.branch: item for item in plan.items if item.branch}
|
||||||
@@ -127,6 +155,43 @@ class TestBranchGc(unittest.TestCase):
|
|||||||
self.assertEqual(items["wip"].skip, "open-pr #3")
|
self.assertEqual(items["wip"].skip, "open-pr #3")
|
||||||
self.assertEqual(items["dirty"].state, "dirty")
|
self.assertEqual(items["dirty"].state, "dirty")
|
||||||
|
|
||||||
|
def test_merged_into_counts_commits_in_an_integration_line(self) -> None:
|
||||||
|
self._integration_line()
|
||||||
|
before = self._items(self._plan(host_ns=True, cwds={}))
|
||||||
|
self.assertEqual(before["feat"].state, "unmerged")
|
||||||
|
self.assertIsNone(before["feat"].merged_into)
|
||||||
|
|
||||||
|
for ref in ("line", "origin/line"):
|
||||||
|
with self.subTest(ref=ref):
|
||||||
|
items = self._items(
|
||||||
|
self._plan(host_ns=True, cwds={}, merged_into=[ref])
|
||||||
|
)
|
||||||
|
self.assertEqual(items["feat"].state, "merged-clean")
|
||||||
|
self.assertEqual(items["feat"].merged_by, f"line:{ref}")
|
||||||
|
self.assertEqual(items["feat"].merged_into, ref)
|
||||||
|
self.assertTrue(items["feat"].eligible)
|
||||||
|
self.assertEqual(items["line"].state, "protected")
|
||||||
|
self.assertEqual(items["start"].state, "unmerged")
|
||||||
|
self.assertEqual(items["merged"].merged_into, "origin/main")
|
||||||
|
|
||||||
|
def test_unknown_merged_into_ref_is_a_warning(self) -> None:
|
||||||
|
plan = self._plan(host_ns=True, cwds={}, merged_into=["no-such-line"])
|
||||||
|
|
||||||
|
self.assertIn("merged-into ref no-such-line not found", plan.warnings)
|
||||||
|
self.assertEqual(self._items(plan)["merged"].state, "merged-clean")
|
||||||
|
|
||||||
|
def test_sizes_and_min_age(self) -> None:
|
||||||
|
items = self._items(self._plan(host_ns=True, cwds={}, sizes=True))
|
||||||
|
self.assertGreater(items["merged"].size_bytes, 0)
|
||||||
|
self.assertIsNone(items["squashed"].size_bytes)
|
||||||
|
self.assertIn("size ", gc._line(items["merged"]))
|
||||||
|
|
||||||
|
young = self._items(self._plan(host_ns=True, cwds={}, min_age=1000))
|
||||||
|
self.assertEqual(young["merged"].skip, "younger than 1000 h")
|
||||||
|
self.assertFalse(young["merged"].eligible)
|
||||||
|
old = self._items(self._plan(host_ns=True, cwds={}, min_age=0))
|
||||||
|
self.assertIsNone(old["merged"].skip)
|
||||||
|
|
||||||
def test_busy_worktree_is_skipped(self) -> None:
|
def test_busy_worktree_is_skipped(self) -> None:
|
||||||
cwds = {4242: os.path.join(self.merged_wt, "sub")}
|
cwds = {4242: os.path.join(self.merged_wt, "sub")}
|
||||||
items = self._items(self._plan(host_ns=True, cwds=cwds))
|
items = self._items(self._plan(host_ns=True, cwds=cwds))
|
||||||
@@ -276,16 +341,34 @@ class TestBranchGc(unittest.TestCase):
|
|||||||
redirect_stdout(out),
|
redirect_stdout(out),
|
||||||
):
|
):
|
||||||
code = gc.branch_gc(
|
code = gc.branch_gc(
|
||||||
[self.repo], apply=False, as_json=True, include=["merged"], exclude=[]
|
[self.repo],
|
||||||
|
apply=False,
|
||||||
|
as_json=True,
|
||||||
|
include=["merged"],
|
||||||
|
exclude=[],
|
||||||
|
merged_into=["origin/main"],
|
||||||
|
sizes=True,
|
||||||
|
min_age=None,
|
||||||
)
|
)
|
||||||
data = json.loads(out.getvalue())
|
data = json.loads(out.getvalue())
|
||||||
|
|
||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
self.assertEqual([i["branch"] for i in data["repos"][0]["items"]], ["merged"])
|
self.assertEqual(data["repos"][0]["merged_into"], ["origin/main"])
|
||||||
|
items = data["repos"][0]["items"]
|
||||||
|
self.assertEqual([i["branch"] for i in items], ["merged"])
|
||||||
|
self.assertEqual(items[0]["merged_into"], "origin/main")
|
||||||
|
self.assertGreater(items[0]["size_bytes"], 0)
|
||||||
with patch.object(gc, "host_pid_namespace", return_value=False):
|
with patch.object(gc, "host_pid_namespace", return_value=False):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
gc.branch_gc(
|
gc.branch_gc(
|
||||||
[self.repo], apply=True, as_json=False, include=[], exclude=[]
|
[self.repo],
|
||||||
|
apply=True,
|
||||||
|
as_json=False,
|
||||||
|
include=[],
|
||||||
|
exclude=[],
|
||||||
|
merged_into=[],
|
||||||
|
sizes=False,
|
||||||
|
min_age=None,
|
||||||
),
|
),
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
@@ -303,6 +386,13 @@ class TestBranchGc(unittest.TestCase):
|
|||||||
"b",
|
"b",
|
||||||
"--exclude",
|
"--exclude",
|
||||||
"c",
|
"c",
|
||||||
|
"--merged-into",
|
||||||
|
"origin/line",
|
||||||
|
"--merged-into",
|
||||||
|
"fork/line",
|
||||||
|
"--sizes",
|
||||||
|
"--min-age",
|
||||||
|
"24",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
@@ -314,7 +404,14 @@ class TestBranchGc(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(exit_ctx.exception.code, 1)
|
self.assertEqual(exit_ctx.exception.code, 1)
|
||||||
run_gc.assert_called_once_with(
|
run_gc.assert_called_once_with(
|
||||||
[self.repo], apply=True, as_json=True, include=["a", "b"], exclude=["c"]
|
[self.repo],
|
||||||
|
apply=True,
|
||||||
|
as_json=True,
|
||||||
|
include=["a", "b"],
|
||||||
|
exclude=["c"],
|
||||||
|
merged_into=["origin/line", "fork/line"],
|
||||||
|
sizes=True,
|
||||||
|
min_age=24.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user