loomwork 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.
loomwork/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # one rename moves the copy marker and the registry file together
2
+ PACKAGE_NAME = __name__
3
+ # what a user types, while the package keeps its own name
4
+ COMMAND_NAME = "loom"
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from loomwork.agents.base import Agent
6
+ from loomwork.agents.claude_code import ClaudeCode
7
+ from loomwork.agents.opencode import OpenCode
8
+
9
+
10
+ def roster(claude_config_dir: Path) -> tuple[Agent, ...]:
11
+ """Every supported agent, in the order a machine converges them — Claude in the config dir of this profile."""
12
+ return (ClaudeCode(claude_config_dir), OpenCode())
13
+
14
+
15
+ __all__ = ["Agent", "ClaudeCode", "OpenCode", "roster"]
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+ from typing import ClassVar
7
+
8
+ from loomwork import COMMAND_NAME, paths
9
+ from loomwork.installation import Installation
10
+
11
+
12
+ class Agent(ABC):
13
+ """A supported agent: global rules and skills go into `rules/` and `skills/` in its config dir.
14
+
15
+ An empty project path means the agent reads `.agents/` in the project itself, so nothing is laid for it there.
16
+ """
17
+
18
+ NAME: ClassVar[str]
19
+ # what the agent is called on PATH
20
+ COMMAND: ClassVar[str]
21
+ # what the agent calls the file it reads instructions from; empty when it reads none
22
+ AGENTS_MD_NAME: ClassVar[str] = ""
23
+ # the dir in a project it reads rules from, recursively
24
+ PROJECT_RULES_DIR: ClassVar[str] = ""
25
+ # the dir in a project it finds skills in, one level deep
26
+ PROJECT_SKILLS_DIR: ClassVar[str] = ""
27
+
28
+ @property
29
+ @abstractmethod
30
+ def config_dir(self) -> Path:
31
+ """Where the agent keeps its own configuration — our presence marker, and the root of its user-level paths."""
32
+
33
+ def bridge(self, installation: Installation) -> tuple[Path, ...]:
34
+ """Lay every user-level surface this agent names a path for, naming what was laid."""
35
+ return (
36
+ *self._publish_agents_md(installation),
37
+ *self._bridge_global_rules(installation),
38
+ *self._bridge_global_skills(installation),
39
+ )
40
+
41
+ def unbridge(self, installation: Installation) -> None:
42
+ """Mirror of bridge(): what is ours goes, anything the user put there stays."""
43
+ installation.clear_owned_in(self.config_dir / "skills")
44
+ rules_dir = self.config_dir / "rules"
45
+ installation.clear_owned_in(rules_dir)
46
+ paths.remove_if_empty(rules_dir)
47
+ if self.AGENTS_MD_NAME:
48
+ published = self.config_dir / self.AGENTS_MD_NAME
49
+ if installation.owned(published):
50
+ published.unlink()
51
+
52
+ def bridge_project(self, installation: Installation, project_dir: Path) -> tuple[Path, ...]:
53
+ """Point the agent at this project's rules and skills, naming where it reads them from."""
54
+ return (
55
+ *self._bridge_project_rules(installation, project_dir),
56
+ *self._bridge_project_skills(installation, project_dir),
57
+ )
58
+
59
+ def unbridge_project(self, installation: Installation, project_dir: Path) -> None:
60
+ """Drop our rule and skill links from the project, and the dirs when they empty out."""
61
+ for relative in (self.PROJECT_RULES_DIR, self.PROJECT_SKILLS_DIR):
62
+ if not relative:
63
+ continue
64
+ linked_dir = project_dir / relative
65
+ self._drop_whole_dir_link(installation, linked_dir)
66
+ installation.clear_owned_in(linked_dir)
67
+ paths.remove_if_empty(linked_dir)
68
+ paths.remove_if_empty(linked_dir.parent)
69
+
70
+ def project_folders(self) -> tuple[str, ...]:
71
+ """The folders this agent's bridges live in at a project's root — what the project's .gitignore must cover."""
72
+ relatives = (self.PROJECT_RULES_DIR, self.PROJECT_SKILLS_DIR)
73
+ return tuple(sorted({Path(relative).parts[0] for relative in relatives if relative}))
74
+
75
+ def is_present(self) -> bool:
76
+ """Whether the agent itself is on this machine — its config dir cannot say, since we create that ourselves."""
77
+ return bool(shutil.which(self.COMMAND))
78
+
79
+ def _publish_agents_md(self, installation: Installation) -> tuple[Path, ...]:
80
+ """Write the source's AGENTS.md under the name this agent reads it by."""
81
+ agents_md = installation.catalog.agents_md
82
+ if not self.AGENTS_MD_NAME or agents_md is None:
83
+ return ()
84
+ published = self.config_dir / self.AGENTS_MD_NAME
85
+ installation.place_file(agents_md, published)
86
+ # a file of the user's own was left alone, so it is not ours to report
87
+ if not installation.owned(published):
88
+ return ()
89
+ return (published,)
90
+
91
+ def _bridge_global_rules(self, installation: Installation) -> tuple[Path, ...]:
92
+ """Copy what the sources hand every project into the agent's own rules dir."""
93
+ rules_dir = self.config_dir / "rules"
94
+ catalog = installation.catalog
95
+ landed = installation.place(catalog.rule_files(catalog.global_rules), rules_dir)
96
+ # a source that hands out nothing leaves no empty dir behind
97
+ paths.remove_if_empty(rules_dir)
98
+ if not landed:
99
+ return ()
100
+ return (rules_dir,)
101
+
102
+ def _bridge_global_skills(self, installation: Installation) -> tuple[Path, ...]:
103
+ """Copy each globally wanted skill into the agent's own skills dir."""
104
+ skills_dir = self.config_dir / "skills"
105
+ catalog = installation.catalog
106
+ if not installation.place(catalog.skill_folders(catalog.global_skills), skills_dir):
107
+ return ()
108
+ return (skills_dir,)
109
+
110
+ def _bridge_project_rules(self, installation: Installation, project_dir: Path) -> tuple[Path, ...]:
111
+ """Link .agents/rules one level down in the agent's rules dir, beside whatever the project keeps there."""
112
+ copied_rules = paths.project_rules_dir(project_dir)
113
+ if not self.PROJECT_RULES_DIR:
114
+ return (copied_rules,)
115
+ rules_dir = project_dir / self.PROJECT_RULES_DIR
116
+ self._drop_whole_dir_link(installation, rules_dir)
117
+ rules_dir.mkdir(parents=True, exist_ok=True)
118
+ rules_link = rules_dir / COMMAND_NAME
119
+ installation.symlink(
120
+ paths.link_target(copied_rules, rules_dir),
121
+ rules_link,
122
+ )
123
+ if not installation.owned(rules_link):
124
+ return ()
125
+ return (rules_link,)
126
+
127
+ def _bridge_project_skills(self, installation: Installation, project_dir: Path) -> tuple[Path, ...]:
128
+ """One link per skill, since skills are found one level deep and the project's own stay beside ours."""
129
+ skills = paths.project_skills_dir(project_dir)
130
+ if not self.PROJECT_SKILLS_DIR:
131
+ if not skills.is_dir():
132
+ return ()
133
+ return (skills,)
134
+ skills_dir = project_dir / self.PROJECT_SKILLS_DIR
135
+ self._drop_whole_dir_link(installation, skills_dir)
136
+ placed = []
137
+ if skills.is_dir():
138
+ placed = sorted(skill for skill in skills.iterdir() if skill.is_dir())
139
+ if not placed and not skills_dir.is_dir():
140
+ return ()
141
+ installation.place_links(
142
+ {skill.name: paths.link_target(skill, skills_dir) for skill in placed},
143
+ skills_dir,
144
+ )
145
+ paths.remove_if_empty(skills_dir)
146
+ if not placed:
147
+ return ()
148
+ return (skills_dir,)
149
+
150
+ def _drop_whole_dir_link(self, installation: Installation, directory: Path) -> None:
151
+ """A link of ours standing in for the whole dir would put the links meant for it inside .agents/."""
152
+ if directory.is_symlink() and installation.owned(directory):
153
+ directory.unlink()
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shlex
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import ClassVar
9
+
10
+ from loomwork import COMMAND_NAME, paths
11
+ from loomwork.agents.base import Agent
12
+ from loomwork.declaration import Declaration
13
+ from loomwork.installation import Installation
14
+
15
+
16
+ class ClaudeCode(Agent):
17
+ """Mechanical loader and the host: its engine loads whatever files exist, so projects get wired — and its SessionStart hook is what runs sync for every agent."""
18
+
19
+ NAME = "claude"
20
+
21
+ COMMAND = "claude"
22
+ # at user level Claude reads CLAUDE.md only
23
+ AGENTS_MD_NAME = "CLAUDE.md"
24
+ # where Claude keeps its config unless it is launched with CONFIG_DIR_ENV
25
+ DEFAULT_CONFIG_DIR: ClassVar[Path] = Path("~/.claude").expanduser()
26
+ CONFIG_DIR_ENV: ClassVar[str] = "CLAUDE_CONFIG_DIR"
27
+ # read recursively, so our rules sit one level down beside the project's own
28
+ PROJECT_RULES_DIR = ".claude/rules"
29
+ PROJECT_SKILLS_DIR = ".claude/skills"
30
+ # a session waits on its start hooks, so ours has to be quick or get out of the way
31
+ HOOK_TIMEOUT_SECONDS: ClassVar[int] = 10
32
+
33
+ def __init__(self, config_dir: Path) -> None:
34
+ self._config_dir = config_dir
35
+
36
+ @property
37
+ def config_dir(self) -> Path:
38
+ return self._config_dir
39
+
40
+ def bridge(self, installation: Installation) -> tuple[Path, ...]:
41
+ """Claude's user-level surfaces, and the session hook that keeps every project in sync."""
42
+ return super().bridge(installation) + self._register_hook(installation)
43
+
44
+ def unbridge(self, installation: Installation) -> None:
45
+ """Mirror of bridge(), the session hook included."""
46
+ self._strip_hook(installation, self.config_dir / "settings.json")
47
+ super().unbridge(installation)
48
+
49
+ def our_hook(self, installation: Installation, hook: dict) -> bool:
50
+ """Ours by the command it calls, whatever path or entry-point name it was written with."""
51
+ # the hook line guards before it calls, so what it actually runs is the last statement
52
+ words = shlex.split(hook.get("command", "").rsplit(";", 1)[-1])
53
+ return bool(words) and Path(words[0]).name == COMMAND_NAME and "sync" in words[1:]
54
+
55
+ def _register_hook(self, installation: Installation) -> tuple[Path, ...]:
56
+ """Put our SessionStart hook in this config dir's settings, keeping every hook that is already there."""
57
+ settings_file = self.config_dir / "settings.json"
58
+ settings = json.loads(settings_file.read_text()) if settings_file.is_file() else {}
59
+ was = json.dumps(settings, sort_keys=True)
60
+ entries = settings.setdefault("hooks", {}).setdefault("SessionStart", [])
61
+ # replace-not-append: our own entries go first, so reruns and a moved install converge on one line
62
+ self._drop_our_entries(installation, entries)
63
+ hook = {"type": "command", "command": self._hook_command(installation), "timeout": self.HOOK_TIMEOUT_SECONDS}
64
+ entries.append({"hooks": [hook]})
65
+ if json.dumps(settings, sort_keys=True) != was:
66
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
67
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
68
+ return (settings_file,)
69
+
70
+ def _strip_hook(self, installation: Installation, settings_file: Path) -> None:
71
+ """Remove exactly our entries from a settings file, and any key or file that empties out."""
72
+ if not settings_file.is_file():
73
+ return
74
+ settings = json.loads(settings_file.read_text())
75
+ entries = settings.get("hooks", {}).get("SessionStart")
76
+ if entries is None:
77
+ return
78
+ self._drop_our_entries(installation, entries)
79
+ if not entries:
80
+ del settings["hooks"]["SessionStart"]
81
+ if not settings["hooks"]:
82
+ del settings["hooks"]
83
+ if not settings:
84
+ # an empty settings file is litter, and Claude reads its absence the same way
85
+ settings_file.unlink()
86
+ paths.remove_if_empty(settings_file.parent)
87
+ return
88
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
89
+
90
+ def _drop_our_entries(self, installation: Installation, entries: list[dict]) -> None:
91
+ for entry in entries:
92
+ entry["hooks"] = [hook for hook in entry.get("hooks", []) if not self.our_hook(installation, hook)]
93
+ entries[:] = [entry for entry in entries if entry.get("hooks")]
94
+
95
+ def _hook_command(self, installation: Installation) -> str:
96
+ """The guard is the opt-in: a project with no declaration never calls us, and the hook still exits clean."""
97
+ # the profile is named, so a session syncs the config dir it was launched with
98
+ return (
99
+ f'[ -f "$CLAUDE_PROJECT_DIR/{Declaration.FILE_NAME}" ] || exit 0; '
100
+ f"{self._loom_command()} --config-dir {shlex.quote(str(self.config_dir))} "
101
+ f'sync --for {self.NAME} "$CLAUDE_PROJECT_DIR"'
102
+ )
103
+
104
+ def _loom_command(self) -> str:
105
+ """How the hook calls us: by the full path we were started from, since its shell may not have us on PATH."""
106
+ found = shutil.which(sys.argv[0])
107
+ # started as anything but our own command (a test runner, python -m), the bare name is all we know
108
+ if not found or Path(found).name != COMMAND_NAME:
109
+ return COMMAND_NAME
110
+ return shlex.quote(str(Path(found).absolute()))
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from loomwork.agents.base import Agent
7
+
8
+
9
+ class OpenCode(Agent):
10
+ """Instruction follower: its global AGENTS.md sends it to the rules, since it loads no rules dir on its own."""
11
+
12
+ NAME = "opencode"
13
+
14
+ COMMAND = "opencode"
15
+ AGENTS_MD_NAME = "AGENTS.md"
16
+
17
+ @property
18
+ def config_dir(self) -> Path:
19
+ # the XDG spec's config root, where an empty value counts as unset — as it does for OpenCode
20
+ return Path(os.environ.get("XDG_CONFIG_HOME") or "~/.config").expanduser() / "opencode"
loomwork/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from loomwork import COMMAND_NAME
9
+ from loomwork.agents import ClaudeCode
10
+ from loomwork.declaration import Declaration
11
+ from loomwork.loom import Loom
12
+ from loomwork.questions import declare_project, wanted_agents, wanted_config_dir, wanted_source
13
+
14
+
15
+ def project_dir(project: Path | None) -> Path:
16
+ return (project or Path.cwd()).resolve()
17
+
18
+
19
+ def profile(config_dir: Path | None) -> Loom:
20
+ """The profile the flag names, else the one in Claude's default config dir."""
21
+ if config_dir:
22
+ return Loom(config_dir.expanduser())
23
+ return Loom(ClaudeCode.DEFAULT_CONFIG_DIR)
24
+
25
+
26
+ @click.group(COMMAND_NAME)
27
+ @click.option(
28
+ "--config-dir",
29
+ type=click.Path(file_okay=False, path_type=Path),
30
+ help="Claude's config dir, which this profile lives in. Defaults to ~/.claude; init asks when it is left out.",
31
+ )
32
+ @click.pass_context
33
+ def cli(context: click.Context, config_dir: Path | None) -> None:
34
+ """Weave shared agent rules through your projects."""
35
+ context.obj = config_dir
36
+
37
+
38
+ @cli.command()
39
+ @click.argument("project", required=False, type=click.Path(file_okay=False, path_type=Path))
40
+ @click.option("--agents", "chosen", metavar="NAMES", help="Comma-separated agents to wire, instead of being asked.")
41
+ @click.option(
42
+ "--source",
43
+ "given_source",
44
+ help="Where the rules come from: a checkout, or <host>/<owner>/<repo>. Asked for when nothing else names one.",
45
+ )
46
+ @click.pass_obj
47
+ def init(config_dir: Path | None, project: Path | None, chosen: str | None, given_source: str | None) -> None:
48
+ """Wire PROJECT and set up this profile.
49
+
50
+ PROJECT defaults to the current directory.
51
+ """
52
+ target = project_dir(project)
53
+ # the profile first: everything below is written into it
54
+ loom = Loom(wanted_config_dir(config_dir, target))
55
+ agents = wanted_agents(loom, target, chosen)
56
+ loom.init(target, agents, wanted_source(loom, target, given_source))
57
+ # the profile is set up either way; the project only joins in when it declares what it wants
58
+ if not Declaration.read(target) and not declare_project(loom, target):
59
+ return
60
+ loom.add(target, agents)
61
+
62
+
63
+ @cli.command()
64
+ @click.argument("project", required=False, type=click.Path(file_okay=False, path_type=Path))
65
+ @click.pass_obj
66
+ def remove(config_dir: Path | None, project: Path | None) -> None:
67
+ """Unwire PROJECT, undoing what loom put there.
68
+
69
+ PROJECT defaults to the current directory.
70
+ """
71
+ profile(config_dir).remove(project_dir(project))
72
+
73
+
74
+ @cli.command()
75
+ @click.argument("project", required=False, type=click.Path(file_okay=False, path_type=Path))
76
+ @click.option("--all", "everywhere", is_flag=True, help="Refresh every wired project instead of this one.")
77
+ @click.option("--for", "asked_by", metavar="NAME", help="The agent asking, as its session hook does.")
78
+ @click.pass_obj
79
+ def sync(config_dir: Path | None, project: Path | None, everywhere: bool, asked_by: str | None) -> None:
80
+ """Refresh PROJECT from its sources.
81
+
82
+ PROJECT defaults to the current directory.
83
+ """
84
+ target = None if everywhere else project_dir(project)
85
+ profile(config_dir).sync(target, asked_by)
86
+
87
+
88
+ @cli.command()
89
+ @click.pass_obj
90
+ def status(config_dir: Path | None) -> None:
91
+ """Show the sources and the wired projects."""
92
+ profile(config_dir).status()
93
+
94
+
95
+ @cli.command()
96
+ @click.pass_obj
97
+ def uninstall(config_dir: Path | None) -> None:
98
+ """Undo everything this profile installed."""
99
+ profile(config_dir).uninstall()
100
+
101
+
102
+ if __name__ == "__main__":
103
+ cli()
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import ClassVar
7
+
8
+ from loomwork.output import error
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class DeclaredSource:
13
+ """One [[source]] block of a declaration: where the rules repo is, and what the project takes from it."""
14
+
15
+ source: str
16
+ rules: tuple[str, ...] = ()
17
+ skills: tuple[str, ...] = ()
18
+
19
+ @classmethod
20
+ def of(cls, declared: dict) -> DeclaredSource:
21
+ return cls(
22
+ source=declared["source"],
23
+ rules=tuple(declared.get("rules", ())),
24
+ skills=tuple(declared.get("skills", ())),
25
+ )
26
+
27
+ def spelled(self) -> list[str]:
28
+ """The block's TOML lines, leaving out an empty rules or skills list."""
29
+ lines = [f'source = "{self.source}"']
30
+ for key, names in (("rules", self.rules), ("skills", self.skills)):
31
+ if names:
32
+ quoted = ", ".join(f'"{name}"' for name in names)
33
+ lines.append(f"{key} = [{quoted}]")
34
+ return lines
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Declaration:
39
+ """A project's .agents.toml: the sources it uses and what it takes from each. Its presence is the opt-in."""
40
+
41
+ FILE_NAME: ClassVar[str] = ".agents.toml"
42
+ TABLE: ClassVar[str] = "source"
43
+
44
+ sources: tuple[DeclaredSource, ...]
45
+
46
+ @classmethod
47
+ def read(cls, project_dir: Path) -> Declaration | None:
48
+ """The project's declaration, or None when it carries none — which reads as the opt-out."""
49
+ declaration_file = project_dir / cls.FILE_NAME
50
+ if not declaration_file.is_file():
51
+ return None
52
+ declared = _parsed(declaration_file)
53
+ listed = declared.get(cls.TABLE)
54
+ if isinstance(listed, list):
55
+ sources = tuple(DeclaredSource.of(entry) for entry in listed if entry.get("source"))
56
+ if not sources:
57
+ return None
58
+ return cls(sources)
59
+ if not declared.get("source"):
60
+ return None
61
+ return cls((DeclaredSource.of(declared),))
62
+
63
+ def write(self, project_dir: Path) -> Path:
64
+ """Write the file into the project, returning its path."""
65
+ declaration_file = project_dir / self.FILE_NAME
66
+ declaration_file.write_text("\n".join(self._lines()) + "\n")
67
+ return declaration_file
68
+
69
+ @property
70
+ def rules(self) -> tuple[str, ...]:
71
+ """Every rule this project asks for, in the order its sources were declared."""
72
+ return tuple(name for declared in self.sources for name in declared.rules)
73
+
74
+ @property
75
+ def skills(self) -> tuple[str, ...]:
76
+ return tuple(name for declared in self.sources for name in declared.skills)
77
+
78
+ def _lines(self) -> list[str]:
79
+ lines = []
80
+ for declared in self.sources:
81
+ lines += [f"[[{self.TABLE}]]", *declared.spelled(), ""]
82
+ return lines[:-1]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Manifest:
87
+ """A source's loom.toml: the sources it builds on, and the rules and skills it hands every project."""
88
+
89
+ FILE_NAME: ClassVar[str] = "loom.toml"
90
+ TABLE: ClassVar[str] = "global"
91
+
92
+ # the same [[source]] blocks a project writes, meaning "I build on this" rather than "I take this"
93
+ builds_on: tuple[str, ...] = ()
94
+ global_rules: tuple[str, ...] = ()
95
+ global_skills: tuple[str, ...] = ()
96
+
97
+ @classmethod
98
+ def read(cls, source: Path) -> Manifest:
99
+ """The source's manifest — an empty one when it has no loom.toml."""
100
+ manifest_file = source / cls.FILE_NAME
101
+ if not manifest_file.is_file():
102
+ return cls()
103
+ document = _parsed(manifest_file)
104
+ marked_global = document.get(cls.TABLE, {})
105
+ blocks = document.get(Declaration.TABLE)
106
+ if not isinstance(blocks, list):
107
+ blocks = []
108
+ return cls(
109
+ builds_on=tuple(entry["source"] for entry in blocks if entry.get("source")),
110
+ global_rules=tuple(marked_global.get("rules", ())),
111
+ global_skills=tuple(marked_global.get("skills", ())),
112
+ )
113
+
114
+
115
+ def _parsed(toml_file: Path) -> dict:
116
+ """The file's contents, or a one-line failure naming it — a session hook's error shows its first line only."""
117
+ try:
118
+ return tomllib.loads(toml_file.read_text())
119
+ except tomllib.TOMLDecodeError as broken:
120
+ error(f"{toml_file}: {broken}")
loomwork/git.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+ from subprocess import CompletedProcess
7
+
8
+ # a session's first answer waits on the hook that updates, so a slow network has to lose rather than hold it up
9
+ UPDATE_TIMEOUT_SECONDS = 8
10
+ CLONE_TIMEOUT_SECONDS = 120
11
+
12
+
13
+ def run(*arguments: str, inside: Path | None = None, timeout: float | None = None) -> CompletedProcess[str] | None:
14
+ """One git call, or None when it failed — the user's own git, so their ssh agent and credential helpers apply."""
15
+ located = ["-C", str(inside)] if inside else []
16
+ # nobody is there to type a password or accept a host key, so git must fail instead of waiting for one
17
+ unattended = {
18
+ "GIT_TERMINAL_PROMPT": "0",
19
+ "GIT_SSH_COMMAND": os.environ.get("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"),
20
+ }
21
+ try:
22
+ ran = subprocess.run(
23
+ ["git", *located, *arguments],
24
+ capture_output=True,
25
+ text=True,
26
+ timeout=timeout,
27
+ check=False,
28
+ env={**os.environ, **unattended},
29
+ )
30
+ except subprocess.TimeoutExpired:
31
+ # macOS ships no timeout(1), so this bound is the only one the hook gets
32
+ return None
33
+ return ran if ran.returncode == 0 else None
34
+
35
+
36
+ def clone(url: str, into: Path) -> bool:
37
+ """Clone the tip alone — what loom reads, with no history to grow."""
38
+ return bool(run("clone", "--quiet", "--depth", "1", url, str(into), timeout=CLONE_TIMEOUT_SECONDS))
39
+
40
+
41
+ def pull(path: Path) -> bool:
42
+ """Fast-forward a checkout, reporting whether it worked."""
43
+ return bool(run("pull", "--ff-only", "--quiet", inside=path, timeout=UPDATE_TIMEOUT_SECONDS))
44
+
45
+
46
+ def match_remote(path: Path) -> bool:
47
+ """Make a clone exactly its remote's tip, rewritten history included, reporting whether it worked."""
48
+ if not run("fetch", "--quiet", "--depth", "1", "origin", inside=path, timeout=UPDATE_TIMEOUT_SECONDS):
49
+ return False
50
+ return bool(run("reset", "--quiet", "--hard", "FETCH_HEAD", inside=path))
51
+
52
+
53
+ def origin(path: Path) -> str | None:
54
+ """The url a checkout came from — None when it has no origin, which a local working copy may not."""
55
+ asked = run("remote", "get-url", "origin", inside=path)
56
+ if asked is None:
57
+ return None
58
+ return asked.stdout.strip().rstrip("/").removesuffix(".git")
59
+
60
+
61
+ def root(path: Path) -> Path:
62
+ """The repo a path sits in, or the path itself when it is in none."""
63
+ for candidate in (path, *path.parents):
64
+ if (candidate / ".git").exists():
65
+ return candidate
66
+ return path