jearless 0.10.4__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.
jearless/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """jearless — Fearless. Jearless. Task tracking and a changelog on bare git."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _package_version
5
+
6
+ # The version is DERIVED from package metadata (which pyproject feeds, which the
7
+ # hook mirrors) — a hardcoded third copy here would drift, and did (0.1.0 in the
8
+ # wheel while the flow lived at 0.10.4). One authored source, everything else derives.
9
+ try:
10
+ __version__ = _package_version("jearless")
11
+ except PackageNotFoundError: # running from a bare source tree, not installed
12
+ __version__ = "0.0.0"
jearless/add.py ADDED
@@ -0,0 +1,122 @@
1
+ """add: append a task to the eternal registry — the one sanctioned YAML write.
2
+
3
+ The duplicate guard (task tooling/10) watches the door: on high keyword
4
+ overlap with existing summaries it lists the look-alikes and demands
5
+ confirmation. The most common duplicate in nature is a re-suggested DONE
6
+ task — hence the derived state next to each match.
7
+ """
8
+
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from ruamel.yaml import YAML
14
+
15
+ from jearless import config, duplicate, gitwrap, registry, state
16
+ from jearless.errors import JearlessError
17
+ from jearless.registry import REGISTRY_NAME
18
+
19
+ # Project names live in branch names: lowercase latin, digits, underscores.
20
+ PROJECT_RE = re.compile(r"^[a-z0-9_]+$")
21
+
22
+ # The header a newborn registry is born with — the same covenant comment as the seeded one.
23
+ _TEMPLATE = """\
24
+ # The eternal jearless task registry (README: frozen facts only, no live state).
25
+ # Status is never stored: available = here without a branch; in_progress = a branch
26
+ # on origin; done = a merge commit in main.
27
+ tasks: {}
28
+ """
29
+
30
+
31
+ def _max_from_archives(cwd: Path, project: str) -> int:
32
+ best = 0
33
+ for path in sorted(cwd.glob("archive_*.yml")):
34
+ best = max(best, registry.load_path(path).max_id(project))
35
+ return best
36
+
37
+
38
+ def _max_from_branches(cwd: Path, cfg: config.Config, project: str, remote: str) -> int | None:
39
+ """None means origin is unreachable: the id is then based on the registry only."""
40
+ try:
41
+ heads = gitwrap.remote_heads(cwd, remote)
42
+ except gitwrap.GitError:
43
+ return None
44
+ best = 0
45
+ for name in heads:
46
+ m = cfg.match_branch(name)
47
+ if m and m.group("project") == project:
48
+ best = max(best, int(m.group("num")))
49
+ return best
50
+
51
+
52
+ def _states(cwd: Path, cfg: config.Config, remote: str) -> dict[tuple[str, int], str]:
53
+ """Derived states for the warning; an unreachable origin simply drops the annotation."""
54
+ try:
55
+ gitwrap.run(["fetch", "--quiet", remote], cwd)
56
+ view = state.compute(cwd, cfg, registry.load(cwd), remote)
57
+ except (gitwrap.GitError, JearlessError):
58
+ return {}
59
+ return {(tv.task.project, tv.task.id): tv.state for tv in view.tasks}
60
+
61
+
62
+ def _guard(cwd: Path, cfg: config.Config, summary: str, remote: str, yes: bool) -> None:
63
+ if yes:
64
+ return
65
+ matches = duplicate.find_similar(registry.load_all(cwd), summary, cfg.duplicate_threshold)
66
+ if not matches:
67
+ return
68
+ states = _states(cwd, cfg, remote)
69
+ print("warning: similar tasks exist:")
70
+ for m in matches:
71
+ derived = states.get((m.task.project, m.task.id))
72
+ tag = f" ({derived})" if derived else ""
73
+ print(f" {m.task.project}/{m.task.id}{tag}: {m.task.summary} — {round(m.score * 100)}%")
74
+ if not sys.stdin.isatty():
75
+ raise JearlessError("declined: no terminal to confirm — re-run with --yes to force")
76
+ if input("Are you sure to add? [y/N] ").strip().lower() not in ("y", "yes"):
77
+ raise JearlessError("declined — the registry is untouched")
78
+
79
+
80
+ def add(
81
+ cwd: Path,
82
+ project: str,
83
+ summary: str,
84
+ points: int | None = None,
85
+ remote: str = "origin",
86
+ yes: bool = False,
87
+ ) -> int:
88
+ if not PROJECT_RE.match(project):
89
+ raise JearlessError("project names are [a-z0-9_]: they live in branch names")
90
+ summary = summary.strip()
91
+ if not summary:
92
+ raise JearlessError("summary must not be empty")
93
+
94
+ cfg = config.load(cwd)
95
+ _guard(cwd, cfg, summary, remote, yes)
96
+ candidates = [registry.load(cwd).max_id(project), _max_from_archives(cwd, project)]
97
+ from_branches = _max_from_branches(cwd, cfg, project, remote)
98
+ if from_branches is not None:
99
+ candidates.append(from_branches)
100
+ else:
101
+ print(
102
+ f"jear: cannot reach '{remote}' — next id checked against the registry only",
103
+ file=sys.stderr,
104
+ )
105
+ next_id = max(candidates) + 1
106
+
107
+ path = cwd / REGISTRY_NAME
108
+ yaml = YAML(typ="rt") # round-trip: comments survive the append
109
+ yaml.indent(mapping=2, sequence=4, offset=2)
110
+ yaml.width = 100000 # never wrap a long summary
111
+ data = yaml.load(path) if path.exists() else None
112
+ if data is None:
113
+ data = yaml.load(_TEMPLATE)
114
+ if not isinstance(data, dict):
115
+ raise JearlessError(f"{REGISTRY_NAME}: expected a mapping at the top level")
116
+ entry: dict = {"id": next_id, "summary": summary}
117
+ if points is not None:
118
+ entry["story_points"] = points
119
+ data.setdefault("tasks", {}).setdefault(project, []).append(entry)
120
+ yaml.dump(data, path)
121
+ print(f"task {project}/{next_id} added — commit tasks.yml to main")
122
+ return next_id
jearless/changelog.py ADDED
@@ -0,0 +1,81 @@
1
+ """changelog: derived from merge commits between refs — printed, never stored (README).
2
+
3
+ Dry-run is the only mode: writing files is the release step's business (task tooling/9).
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ from jearless import config, gitwrap, registry, state
9
+ from jearless.errors import JearlessError
10
+
11
+ _UNRELEASED = "Unreleased"
12
+
13
+
14
+ def _summary_for(reg: registry.Registry, project: str, num: int, slug: str | None) -> str:
15
+ """The registry (union with archives) first; the branch slug is the fallback (README)."""
16
+ for task in reg.tasks.get(project, []):
17
+ if task.id == num:
18
+ return task.summary
19
+ return (slug or "task").replace("-", " ")
20
+
21
+
22
+ def _entries_from_history(
23
+ cwd: Path, cfg: config.Config, reg: registry.Registry, rev_range: str
24
+ ) -> tuple[dict[str, list[tuple[tuple[str, int], str]]], int]:
25
+ sections: dict[str, list[tuple[tuple[str, int], str]]] = {}
26
+ skipped = 0
27
+ subjects = gitwrap.lines(["log", rev_range, "--merges", "--format=%s"], cwd)
28
+ for subject in subjects:
29
+ branch = state.branch_from_merge_subject(subject)
30
+ m = cfg.match_branch(branch) if branch else None
31
+ if not m:
32
+ skipped += 1 # off-taxonomy merge: the visible hole, counted aloud
33
+ continue
34
+ project, num = m.group("project"), int(m.group("num"))
35
+ summary = _summary_for(reg, project, num, m.group("slug"))
36
+ line = cfg.line_template.format(
37
+ type=m.group("type"),
38
+ project=project,
39
+ num=num,
40
+ slug=m.group("slug") or "",
41
+ summary=summary,
42
+ )
43
+ sections.setdefault(m.group("type"), []).append(((project, num), line))
44
+ return sections, skipped
45
+
46
+
47
+ def render(
48
+ cwd: Path, from_tag: str | None = None, to_tag: str | None = None, header: str | None = None
49
+ ) -> str:
50
+ """The changelog as text; `header` overrides the section title (used by release)."""
51
+ cfg = config.load(cwd)
52
+ reg = registry.load_all(cwd)
53
+ to_ref = to_tag or "HEAD"
54
+ rev_range = f"{from_tag}..{to_ref}" if from_tag else to_ref
55
+ try:
56
+ sections, skipped = _entries_from_history(cwd, cfg, reg, rev_range)
57
+ date = gitwrap.out(["log", "-1", "--format=%cd", "--date=short", to_ref], cwd)
58
+ except gitwrap.GitError as exc:
59
+ raise JearlessError(f"cannot read history {rev_range}: {exc}") from exc
60
+
61
+ lines = ["# Changelog"]
62
+ title = header or to_tag or _UNRELEASED
63
+ lines.append(f"## {title} ({date})" if date else f"## {title}")
64
+ for prefix, section_title in cfg.sections.items():
65
+ entries = sorted(sections.pop(prefix, []))
66
+ if not entries:
67
+ continue
68
+ lines.append(f"### {section_title}")
69
+ lines.extend(f"- {line}" for _, line in entries)
70
+ others = sorted((key, line) for entries in sections.values() for key, line in entries)
71
+ if others:
72
+ lines.append("### Other")
73
+ lines.extend(f"- {line}" for _, line in others)
74
+ if skipped:
75
+ lines.append(f"# {skipped} off-taxonomy merge(s) skipped — the visible hole (README)")
76
+ return "\n".join(lines) + "\n"
77
+
78
+
79
+ def run(cwd: Path, from_tag: str | None = None, to_tag: str | None = None) -> int:
80
+ print(render(cwd, from_tag=from_tag, to_tag=to_tag), end="")
81
+ return 0
jearless/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """The CLI frame: subcommands per PLAN.md, phases 1-4.
2
+
3
+ In Phase 0 the subcommands are stubs, but empty states are handled by the
4
+ live modules, without crashing (Phase 0 DoD).
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from jearless import __version__
12
+ from jearless import add as add_mod
13
+ from jearless import changelog as changelog_mod
14
+ from jearless import doctor as doctor_mod
15
+ from jearless import release as release_mod
16
+ from jearless import retro as retro_mod
17
+ from jearless import sprint as sprint_mod
18
+ from jearless import take as take_mod
19
+ from jearless import timeline as timeline_mod
20
+ from jearless.errors import JearlessError
21
+
22
+ PHASES = {
23
+ "add": 1,
24
+ "take": 1,
25
+ "sprint": 1,
26
+ "changelog": 2,
27
+ "doctor": 2,
28
+ "timeline": 3,
29
+ "retro": 3,
30
+ "archive": 4,
31
+ }
32
+
33
+
34
+ def build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="jear",
37
+ description="Fearless. Jira-less. Task tracking and changelog on bare git.",
38
+ )
39
+ parser.add_argument("--version", action="version", version=f"jearless {__version__}")
40
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
41
+
42
+ p = sub.add_parser("add", help="add a task to the registry (Phase 1)")
43
+ p.add_argument("project")
44
+ p.add_argument("summary")
45
+ p.add_argument("--points", type=int, default=None)
46
+ p.add_argument("--yes", action="store_true", help="skip the duplicate guard")
47
+
48
+ p = sub.add_parser("take", help="take a task: branch + push (Phase 1)")
49
+ p.add_argument("project")
50
+ p.add_argument("id", type=int)
51
+
52
+ sub.add_parser("sprint", help="sprint: registry × branches × merge log join (Phase 1)")
53
+
54
+ p = sub.add_parser(
55
+ "changelog", help="changelog from merge commits (Phase 2); dry-run is the only mode"
56
+ )
57
+ p.add_argument("--from", dest="from_tag", default=None, metavar="TAG")
58
+ p.add_argument("--to", dest="to_tag", default=None, metavar="TAG")
59
+ p.add_argument("--dry-run", action="store_true", help="accepted for symmetry; always dry")
60
+
61
+ sub.add_parser("doctor", help="diagnostics: unattributed commits etc. (Phase 2)")
62
+
63
+ p = sub.add_parser("timeline", help="actual timeline, mermaid (Phase 3)")
64
+ group = p.add_mutually_exclusive_group()
65
+ group.add_argument("--live", action="store_true")
66
+ group.add_argument("--snapshot", metavar="TAG")
67
+
68
+ p = sub.add_parser("retro", help="plan versus actual (Phase 3)")
69
+ p.add_argument("plan")
70
+
71
+ p = sub.add_parser(
72
+ "release", help="tag + changelog + timeline snapshot in one move (task tooling/9)"
73
+ )
74
+ p.add_argument("--tag", default=None, metavar="TAG")
75
+
76
+ sub.add_parser("archive", help="move done tasks out (Phase 4, on trigger)")
77
+
78
+ return parser
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ args = build_parser().parse_args(argv)
83
+ cwd = Path.cwd()
84
+ try:
85
+ if args.command == "add":
86
+ add_mod.add(cwd, args.project, args.summary, args.points, yes=args.yes)
87
+ return 0
88
+ if args.command == "take":
89
+ print(take_mod.take(cwd, args.project, args.id))
90
+ return 0
91
+ if args.command == "sprint":
92
+ return sprint_mod.run(cwd)
93
+ if args.command == "changelog":
94
+ return changelog_mod.run(cwd, from_tag=args.from_tag, to_tag=args.to_tag)
95
+ if args.command == "doctor":
96
+ return doctor_mod.run(cwd)
97
+ if args.command == "timeline":
98
+ return timeline_mod.run(cwd, snapshot=args.snapshot)
99
+ if args.command == "retro":
100
+ return retro_mod.run(cwd, Path(args.plan))
101
+ if args.command == "release":
102
+ return release_mod.run(cwd, tag=args.tag)
103
+ print(f"not implemented (Phase {PHASES[args.command]}) — see PLAN.md")
104
+ return 0
105
+ except JearlessError as exc:
106
+ print(f"jear: {exc}", file=sys.stderr)
107
+ return 1
jearless/config.py ADDED
@@ -0,0 +1,69 @@
1
+ """The .jearless.toml config: the branch regex, the changelog line template, sections."""
2
+
3
+ import re
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from jearless.errors import JearlessError
9
+
10
+ CONFIG_NAME = ".jearless.toml"
11
+
12
+ # Slashes are the structural separators: type/project/number-slug (README).
13
+ DEFAULT_BRANCH_PATTERN = (
14
+ r"^(?P<type>[a-z]+)/(?P<project>[a-z0-9_]+)/(?P<num>\d+)(?:-(?P<slug>[a-z0-9-]+))?$"
15
+ )
16
+ REQUIRED_GROUPS = ("type", "project", "num")
17
+ DEFAULT_SECTIONS = {"feature": "Added", "fix": "Fixed"}
18
+ DEFAULT_LINE_TEMPLATE = "{project}-{num}: {summary}"
19
+ DEFAULT_DUPLICATE_THRESHOLD = 0.6
20
+
21
+
22
+ class ConfigError(JearlessError):
23
+ """The config exists but is invalid."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Config:
28
+ branch_pattern: re.Pattern
29
+ sections: dict[str, str]
30
+ line_template: str
31
+ duplicate_threshold: float = DEFAULT_DUPLICATE_THRESHOLD
32
+
33
+ def match_branch(self, name: str) -> re.Match | None:
34
+ return self.branch_pattern.match(name)
35
+
36
+
37
+ def _compile_branch_pattern(pattern: str) -> re.Pattern:
38
+ try:
39
+ compiled = re.compile(pattern)
40
+ except re.error as exc:
41
+ raise ConfigError(f"branch regex does not compile: {exc}") from exc
42
+ missing = [g for g in REQUIRED_GROUPS if g not in compiled.groupindex]
43
+ if missing:
44
+ raise ConfigError(f"branch regex misses named groups: {', '.join(missing)}")
45
+ return compiled
46
+
47
+
48
+ def load(cwd: Path) -> Config:
49
+ """A missing .jearless.toml is not an error: defaults apply."""
50
+ path = cwd / CONFIG_NAME
51
+ data: dict = {}
52
+ if path.exists():
53
+ try:
54
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
55
+ except tomllib.TOMLDecodeError as exc:
56
+ raise ConfigError(f"{CONFIG_NAME}: {exc}") from exc
57
+ branch = data.get("branch", {})
58
+ changelog = data.get("changelog", {})
59
+ duplicate = data.get("duplicate", {})
60
+ threshold = duplicate.get("threshold", DEFAULT_DUPLICATE_THRESHOLD)
61
+ if not isinstance(threshold, (int, float)) or not 0 < threshold <= 1:
62
+ raise ConfigError("[duplicate] threshold must be a number in (0, 1]")
63
+ sections = {**DEFAULT_SECTIONS, **changelog.get("sections", {})}
64
+ return Config(
65
+ branch_pattern=_compile_branch_pattern(branch.get("pattern", DEFAULT_BRANCH_PATTERN)),
66
+ sections=sections,
67
+ line_template=changelog.get("line_template", DEFAULT_LINE_TEMPLATE),
68
+ duplicate_threshold=float(threshold),
69
+ )
jearless/doctor.py ADDED
@@ -0,0 +1,83 @@
1
+ """doctor: the visible hole — unattributed commits, naming violations, a sick registry.
2
+
3
+ The audit walks the first-parent chain of main: a commit there is attributed
4
+ when it is a merge (the branch name is the key) or touches nothing but
5
+ sanctioned artifacts — the registry, the changelog, timeline snapshots (README).
6
+ Commits that arrived inside a --no-ff merge hang off second parents and are
7
+ attributed by that merge. Everything else on the chain is a hole: a squash,
8
+ a fast-forward or plain drift.
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ from jearless import config, gitwrap, registry
14
+ from jearless.gitwrap import GitError
15
+ from jearless.registry import REGISTRY_NAME, RegistryError
16
+
17
+
18
+ def _check_registry(cwd: Path, findings: list[str]) -> None:
19
+ try:
20
+ registry.load(cwd)
21
+ except RegistryError as exc:
22
+ findings.append(f"registry: {exc}")
23
+
24
+
25
+ def _check_branches(cwd: Path, cfg: config.Config, findings: list[str], remote: str) -> None:
26
+ try:
27
+ heads = gitwrap.remote_heads(cwd, remote)
28
+ except GitError:
29
+ findings.append(f"{remote}: unreachable — branch checks skipped")
30
+ return
31
+ known = set(cfg.sections) | {"chore"}
32
+ for name in heads:
33
+ prefix = name.split("/", 1)[0]
34
+ if prefix in known and not cfg.match_branch(name):
35
+ findings.append(f"branch: '{name}' starts like a task branch but misses the pattern")
36
+
37
+
38
+ # Sanctioned artifacts: bookkeeping, not task work. env.yml version bumps
39
+ # normally ride branches via the hook; a direct one is human bookkeeping.
40
+ _SANCTIONED_EXACT = frozenset({REGISTRY_NAME, "CHANGELOG.md", "env.yml"})
41
+ _SANCTIONED_PREFIXES = ("archive_", "snapshots/")
42
+
43
+
44
+ def _is_sanctioned_edit(cwd: Path, sha: str) -> bool:
45
+ files = gitwrap.lines(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd)
46
+ return all(
47
+ f in _SANCTIONED_EXACT or f.startswith(_SANCTIONED_PREFIXES) for f in files
48
+ )
49
+
50
+
51
+ def _check_unattributed(cwd: Path, findings: list[str], remote: str) -> None:
52
+ ref = f"{remote}/main"
53
+ try:
54
+ # first-parent only: WIP that arrived inside a --no-ff merge is attributed
55
+ # by that merge, not audited on its own
56
+ log = gitwrap.out(["log", "--first-parent", ref, "--format=%H%x1f%P%x1f%s"], cwd)
57
+ except GitError:
58
+ findings.append(f"main: no {ref} — nothing to audit yet")
59
+ return
60
+ for line in log.splitlines():
61
+ if not line.strip():
62
+ continue
63
+ sha, parents, subject = line.split("\x1f", 2)
64
+ if len(parents.split()) > 1:
65
+ continue # a merge: attributed by its branch name
66
+ if _is_sanctioned_edit(cwd, sha):
67
+ continue # a sanctioned human edit: a task, the changelog, a snapshot
68
+ findings.append(
69
+ f"main: unattributed commit {sha[:7]} \"{subject}\" "
70
+ f"(not a merge, not a registry edit) — squash or fast-forward suspected"
71
+ )
72
+
73
+
74
+ def run(cwd: Path, remote: str = "origin") -> int:
75
+ findings: list[str] = []
76
+ _check_registry(cwd, findings)
77
+ cfg = config.load(cwd)
78
+ _check_branches(cwd, cfg, findings, remote)
79
+ _check_unattributed(cwd, findings, remote)
80
+ for line in findings:
81
+ print(line)
82
+ print(f"doctor: {len(findings)} finding(s)")
83
+ return 1 if findings else 0
jearless/duplicate.py ADDED
@@ -0,0 +1,86 @@
1
+ """The duplicate guard (task tooling/10): cheap keyword overlap with mini-IDF.
2
+
3
+ Simple means by charter: tokenize, weight each word by its scarcity across the
4
+ registry's own summaries, score with weighted Jaccard corrected by containment.
5
+ No stemming frameworks, no edit distance — a duplicate is the same words in a
6
+ different order, and reordering is not the enemy.
7
+ """
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ from jearless import registry
13
+
14
+ _STOPWORDS = frozenset(
15
+ [
16
+ "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from",
17
+ "has", "have", "in", "into", "is", "it", "its", "of", "on", "or",
18
+ "the", "this", "to", "with", "you", "your",
19
+ ]
20
+ )
21
+ _WORD_RE = re.compile(r"[a-z0-9]+")
22
+
23
+
24
+ def tokenize(summary: str) -> list[str]:
25
+ """Lowercase alphanumeric runs minus stopwords; a naive plural strip.
26
+
27
+ The strip misses branch/branches-class pairs — acceptable for a guard:
28
+ it is a net, not a wall, and misses cost nothing while false alarms cost
29
+ a confirmation each.
30
+ """
31
+ words: list[str] = []
32
+ for raw in _WORD_RE.findall(summary.lower()):
33
+ if raw in _STOPWORDS:
34
+ continue
35
+ if len(raw) > 3 and raw.endswith("s") and not raw.endswith("ss"):
36
+ raw = raw[:-1] # tasks → task; class stays class
37
+ words.append(raw)
38
+ return words
39
+
40
+
41
+ def _weights(summaries: list[str]) -> dict[str, float]:
42
+ """Mini-IDF: a word occurring in many summaries weighs almost nothing.
43
+
44
+ This is the whole defense against domain noise: "task" and "registry" are
45
+ in half of our summaries and must not trigger matches on their own.
46
+ """
47
+ df: dict[str, int] = {}
48
+ for summary in summaries:
49
+ for word in set(tokenize(summary)):
50
+ df[word] = df.get(word, 0) + 1
51
+ return {word: 1.0 / n for word, n in df.items()}
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Match:
56
+ task: registry.Task
57
+ score: float # 0..1
58
+
59
+
60
+ def _score(new: set[str], old: set[str], weights: dict[str, float]) -> float:
61
+ def weight(word: str) -> float:
62
+ return weights.get(word, 1.0) # an unseen word is maximally informative
63
+
64
+ union = new | old
65
+ if not union:
66
+ return 0.0
67
+ shared = new & old
68
+ jaccard = sum(weight(w) for w in shared) / sum(weight(w) for w in union)
69
+ denom = min(sum(weight(w) for w in new), sum(weight(w) for w in old))
70
+ containment = sum(weight(w) for w in shared) / denom if denom else 0.0
71
+ # containment guards the common duplicate: a short summary inside a long one
72
+ return max(jaccard, containment)
73
+
74
+
75
+ def find_similar(
76
+ reg: registry.Registry, summary: str, threshold: float, limit: int = 3
77
+ ) -> list[Match]:
78
+ """Existing tasks whose summaries overlap above the threshold, best first."""
79
+ new = set(tokenize(summary))
80
+ weights = _weights([t.summary for t in reg.all()])
81
+ matches: list[Match] = []
82
+ for task in reg.all():
83
+ score = _score(new, set(tokenize(task.summary)), weights)
84
+ if score >= threshold:
85
+ matches.append(Match(task, score))
86
+ return sorted(matches, key=lambda m: m.score, reverse=True)[:limit]
jearless/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """The base error: reaches the user as a single line, without a traceback."""
2
+
3
+
4
+ class JearlessError(RuntimeError):
5
+ """Parent of all jearless errors — the CLI catches it and prints it briefly."""
jearless/gitwrap.py ADDED
@@ -0,0 +1,69 @@
1
+ """Git via subprocess: no intermediary libraries, all flags in plain sight.
2
+
3
+ Rationale in PLAN.md: jearless is about the precise semantics of git,
4
+ so git calls must not be hidden behind wrappers like GitPython.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from jearless.errors import JearlessError
12
+
13
+
14
+ class GitError(JearlessError):
15
+ """git exited with a non-zero code."""
16
+
17
+
18
+ def _git(args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
19
+ return subprocess.run(
20
+ ["git", *args],
21
+ cwd=cwd,
22
+ capture_output=True,
23
+ encoding="utf-8",
24
+ errors="replace",
25
+ check=False,
26
+ env={**os.environ, **env} if env else None,
27
+ )
28
+
29
+
30
+ def run(
31
+ args: list[str], cwd: Path, env: dict[str, str] | None = None
32
+ ) -> subprocess.CompletedProcess:
33
+ """Run git; on failure raise GitError with stderr as a single line."""
34
+ proc = _git(args, cwd, env)
35
+ if proc.returncode != 0:
36
+ detail = proc.stderr.strip() or proc.stdout.strip()
37
+ raise GitError(f"git {' '.join(args)}: {detail}")
38
+ return proc
39
+
40
+
41
+ def out(args: list[str], cwd: Path) -> str:
42
+ return run(args, cwd).stdout.strip()
43
+
44
+
45
+ def lines(args: list[str], cwd: Path) -> list[str]:
46
+ return [ln for ln in run(args, cwd).stdout.splitlines() if ln.strip()]
47
+
48
+
49
+ def is_repo(cwd: Path) -> bool:
50
+ proc = _git(["rev-parse", "--is-inside-work-tree"], cwd)
51
+ return proc.returncode == 0 and proc.stdout.strip() == "true"
52
+
53
+
54
+ def has_commits(cwd: Path) -> bool:
55
+ """A fresh `git init` is a repository without commits: a Phase 0 empty state."""
56
+ proc = _git(["rev-parse", "--verify", "--quiet", "HEAD"], cwd)
57
+ return proc.returncode == 0
58
+
59
+
60
+ def remote_heads(cwd: Path, remote: str = "origin", pattern: str | None = None) -> dict[str, str]:
61
+ """Branches on the remote — the source of truth about occupancy (README), not a cache."""
62
+ args = ["ls-remote", "--heads", remote]
63
+ if pattern:
64
+ args.append(pattern)
65
+ heads: dict[str, str] = {}
66
+ for ln in lines(args, cwd):
67
+ sha, _, ref = ln.partition("\t")
68
+ heads[ref.removeprefix("refs/heads/")] = sha
69
+ return heads