diff --git a/README.md b/README.md index d6034f4..f018a56 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,10 @@ run `minigit push 127.0.0.1:9418 main --token demo-token`. Push transfers missin objects and advances the remote branch only after validating its history; it does not check out files or update the server's HEAD or index. Repeating the push reports that the branch is up to date. Use a disposable token for this local demo. -Pull remains a handshake placeholder; fetching and restoring a branch through -`minigit pull` is not implemented yet. +From another initialized repository, run +`minigit pull 127.0.0.1:9418 main --token demo-token` to fetch the complete history +and restore the branch. See the network workflow below for multiple branches +and conflict resolution. ## Layout @@ -136,6 +138,35 @@ A lint check catches unused imports, undefined names, and similar mistakes. A formatting check keeps everyone's code looking the same so diffs show real changes instead of whitespace. Tests are the ones you write in `tests/`. +## Sharing Repos Over the Network + +One repo serves, others push and pull. Every command needs the server's +token, which is never printed back. Use a disposable token for local examples; +stop the server with Ctrl-C when finished. + +```bash +# in the repo that acts as the remote +minigit serve --port 9418 --token demo-token + +# in any other repo +minigit ls-remote 127.0.0.1:9418 --token demo-token # list the remote's branches +minigit push 127.0.0.1:9418 main --token demo-token # or --all for every local branch +minigit fetch 127.0.0.1:9418 main --token demo-token # download history only, no ref changes +minigit pull 127.0.0.1:9418 main --token demo-token # fetch, then integrate; or --all +``` + +- A push is rejected if the remote branch moved since you last synced + (`push rejected: ... - pull first`). Pull, merge, then push again. +- Pull fetches and validates the whole history before changing anything. + If the transfer fails, your refs, HEAD, index, and files are untouched, + and running the same pull again reuses what already arrived. +- Pulling a branch you don't have checked out creates or fast-forwards it + without touching your files. A diverged one is refused: check it out and + pull again to merge. +- Pulling into the checked-out branch fast-forwards or merges it. If both + sides changed the same lines, pull stops with the conflicting paths; fix + the conflict markers, `minigit add` the files, and `minigit commit`. + ## Docs - [Onboarding guide](https://docs.google.com/document/d/1D77Ncmtunxj7GVADytHo1An6BKzaV-Ep7AxAYfL405k/edit?tab=t.0) - diff --git a/minigit/cli.py b/minigit/cli.py index 512230d..a554979 100644 --- a/minigit/cli.py +++ b/minigit/cli.py @@ -70,7 +70,17 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() - args = parser.parse_args(argv) + args, unknown = parser.parse_known_args(argv) + if unknown: + if ( + getattr(args, "command", None) in {"push", "pull"} + and len(unknown) == 1 + and not unknown[0].startswith("-") + and getattr(args, "branch", None) is None + ): + args.branch = unknown[0] + else: + parser.error(f"unrecognized arguments: {' '.join(unknown)}") handler = getattr(args, "handler", None) if handler is None: diff --git a/minigit/remote.py b/minigit/remote.py index 05ebfeb..aa44d9d 100644 --- a/minigit/remote.py +++ b/minigit/remote.py @@ -11,11 +11,20 @@ import os import socket +import sys +from contextlib import contextmanager from minigit.commits import CommitManager -from minigit.errors import NetworkProtocolError, ObjectCorruptError, ObjectNotFoundError +from minigit.errors import ( + MiniGitError, + NetworkProtocolError, + ObjectCorruptError, + ObjectNotFoundError, +) from minigit.objects import ObjectStore +MAX_PROTOCOL_LINE_BYTES = 65536 + def send_line(sock, text: str) -> None: """Send one line of the wire protocol,""" @@ -33,6 +42,8 @@ def receive_line(sock, buf: bytearray) -> str: """ while b"\n" not in buf: + if len(buf) > MAX_PROTOCOL_LINE_BYTES: + raise NetworkProtocolError("protocol line is too long") chunk = sock.recv(4096) if not chunk: raise NetworkProtocolError("connection closed mid-line") @@ -40,6 +51,8 @@ def receive_line(sock, buf: bytearray) -> str: line, _, rest = buf.partition(b"\n") buf[:] = rest + if len(line) > MAX_PROTOCOL_LINE_BYTES: + raise NetworkProtocolError("protocol line is too long") try: return line.decode("utf-8") except UnicodeDecodeError as exc: @@ -78,11 +91,11 @@ def _valid_branch(name: str) -> bool: characters that would break a ref file or a protocol line. """ - return ( - bool(name) - and all(part not in {"", ".", ".."} for part in name.split("/")) - and not any(char in name for char in "\0\\\r\n ") - ) + try: + CommitManager.validate_branch_name(name) + except MiniGitError: + return False + return True def _rejection_reason(reply: str) -> str: @@ -143,7 +156,7 @@ def _parse_address(self, address: str) -> tuple[str, int]: host, port = parts if host and port: - if port.isnumeric(): + if port.isascii() and port.isdigit() and len(port) <= 5: int_port = int(port) if 1 <= int_port <= 65535: return host, int_port @@ -154,6 +167,57 @@ def _parse_address(self, address: str) -> tuple[str, int]: else: raise NetworkProtocolError(f"address must be host:port, got {address!r}") + def _preflight(self, remote_address: str, token: str, operation: str) -> tuple[str, int]: + """Validate the address and token before touching any local or remote state.""" + + host, port = self._parse_address(remote_address) + if len(token) == 0: + raise NetworkProtocolError(f"{operation} needs a token: pass --token") + if any(ord(char) < 32 or ord(char) == 127 for char in token): + raise NetworkProtocolError(f"{operation} token contains invalid control characters") + return host, port + + @contextmanager + def _session(self, host: str, port: int, token: str, operation: str): + """Connect, authenticate, and yield `(sock, buf)`; always closes the socket. + + Transport failures (refused, timeout, reset) and bad objects met + mid-session all surface as `NetworkProtocolError`. Error messages + name the operation and `host:port` only - never the token. + """ + + try: + sock = socket.create_connection((host, port), timeout=5) + except OSError as exc: + raise NetworkProtocolError(f"could not connect to {host}:{port}: {exc}") from exc + + try: + buf = bytearray() + send_line(sock, f"AUTH {token}") + if receive_line(sock, buf) != "OK": + raise NetworkProtocolError(f"{operation} to {host}:{port}: authentication failed") + yield sock, buf + except NetworkProtocolError as exc: + raise NetworkProtocolError(str(exc).replace(token, "")) from None + except (OSError, ObjectNotFoundError, ObjectCorruptError) as exc: + reason = str(exc).replace(token, "") + raise NetworkProtocolError(f"{operation} to {host}:{port} failed: {reason}") from None + finally: + sock.close() + + def _request_ref(self, sock, buf: bytearray, branch: str) -> str: + """Ask the remote for `branch`'s tip; returns a commit hash or `-` if it has none.""" + + send_line(sock, f"REF {branch}") + reply = receive_line(sock, buf) + fields = reply.split(" ") + if len(fields) != 3 or fields[:2] != ["REF", branch]: + raise NetworkProtocolError(f"invalid REF response: {reply!r}") + remote_hash = fields[2] + if remote_hash != "-" and not _valid_hash(remote_hash): + raise NetworkProtocolError(f"invalid remote hash: {remote_hash!r}") + return remote_hash + def push(self, remote_address: str, branch: str, token: str) -> None: """Send local commits on `branch` to the remote, rejecting if it has diverged. @@ -168,34 +232,19 @@ def push(self, remote_address: str, branch: str, token: str) -> None: expected old tip there too, so a push that raced ours still loses. """ - host, port = self._parse_address(remote_address) - if len(token) == 0: - raise NetworkProtocolError("push needs a token: pass --token") + host, port = self._preflight(remote_address, token, "push") + if not _valid_branch(branch): + raise NetworkProtocolError(f"invalid branch name: {branch!r}") - local_hash = self.commits.read_ref(branch) + try: + local_hash = self.commits.read_ref(branch) + except (MiniGitError, OSError) as exc: + raise NetworkProtocolError(f"cannot read local branch {branch!r}: {exc}") from exc if local_hash is None: raise NetworkProtocolError(f"local branch {branch!r} has no commits to push") - try: - sock = socket.create_connection((host, port), timeout=5) - except OSError as exc: - raise NetworkProtocolError(f"could not connect to {host}:{port}: {exc}") from exc - - try: - buf = bytearray() - send_line(sock, f"AUTH {token}") - reply = receive_line(sock, buf) - if reply != "OK": - raise NetworkProtocolError(f"auth failed: {reply}") - - send_line(sock, f"REF {branch}") - reply = receive_line(sock, buf) - fields = reply.split(" ") - if len(fields) != 3 or fields[:2] != ["REF", branch]: - raise NetworkProtocolError(f"invalid REF response: {reply!r}") - remote_hash = fields[2] - if remote_hash != "-" and not _valid_hash(remote_hash): - raise NetworkProtocolError(f"invalid remote hash: {remote_hash!r}") + with self._session(host, port, token, "push") as (sock, buf): + remote_hash = self._request_ref(sock, buf, branch) if remote_hash == local_hash: send_line(sock, "DONE") @@ -226,12 +275,6 @@ def push(self, remote_address: str, branch: str, token: str) -> None: raise NetworkProtocolError(f"push rejected: {_rejection_reason(reply)}") print(f"{branch} now at {local_hash}") - except NetworkProtocolError: - raise - except (OSError, ObjectNotFoundError, ObjectCorruptError) as exc: - raise NetworkProtocolError(f"push to {host}:{port} failed: {exc}") from exc - finally: - sock.close() def _check_fast_forward(self, branch: str, remote_hash: str, local_hash: str) -> None: """Raise unless moving the remote from `remote_hash` to `local_hash` is a fast-forward. @@ -268,88 +311,112 @@ def _put_object(self, sock, buf: bytearray, obj_hash: str) -> None: if reply != "OK": raise NetworkProtocolError(f"remote rejected {obj_hash}: {reply}") - def pull(self, remote_address: str, branch: str, token: str) -> None: - """Fetch `branch` and its complete reachable object graph.""" + def list_refs(self, remote_address: str, token: str) -> dict[str, str]: + """Return every branch the remote advertises, as `{name: commit hash}`.""" + + host, port = self._preflight(remote_address, token, "ls-remote") + + refs: dict[str, str] = {} + with self._session(host, port, token, "ls-remote") as (sock, buf): + send_line(sock, "REFS") + while (line := receive_line(sock, buf)) != "END": + fields = line.split(" ") + if ( + len(fields) != 3 + or fields[0] != "REF" + or not _valid_branch(fields[1]) + or not _valid_hash(fields[2]) + or fields[1] in refs + ): + raise NetworkProtocolError(f"invalid REFS entry: {line!r}") + refs[fields[1]] = fields[2] + send_line(sock, "DONE") + return refs + + def fetch(self, remote_address: str, branch: str, token: str) -> str: + """Download `branch`'s complete history from the remote; return its tip hash. + + The tip is read once and the whole transfer is pinned to it, so the + remote branch moving mid-fetch can't switch snapshots. Only objects + missing locally are requested, but the walk still descends through + objects that are already present - an interrupted fetch can leave a + commit stored without its tree - so on return every commit, tree, + and blob reachable from the tip is present, hash-checked, and of the + type its parent says it is. No ref, HEAD, index, or file is touched; + objects received before a failure stay stored for a retry to reuse. + """ - host, port = self._parse_address(remote_address) + host, port = self._preflight(remote_address, token, "fetch") if not _valid_branch(branch): raise NetworkProtocolError(f"invalid branch name: {branch!r}") - if len(token) == 0: - raise NetworkProtocolError("pull needs a token: pass --token") - - try: - sock = socket.create_connection((host, port), timeout=5) - except OSError as exc: - raise NetworkProtocolError(f"could not connect to {host}:{port}: {exc}") from exc - - try: - buf = bytearray() - send_line(sock, f"AUTH {token}") - reply = receive_line(sock, buf) - if reply != "OK": - raise NetworkProtocolError(f"auth failed: {reply}") - - send_line(sock, f"REF {branch}") - reply = receive_line(sock, buf) - fields = reply.split(" ") - if len(fields) != 3 or fields[:2] != ["REF", branch]: - raise NetworkProtocolError(f"invalid REF response: {reply!r}") - remote_hash = fields[2] - if remote_hash == "-": - raise NetworkProtocolError(f"remote branch {branch!r} has no commits") - if not _valid_hash(remote_hash): - raise NetworkProtocolError(f"invalid remote hash: {remote_hash!r}") - - commits_to_visit = [remote_hash] - visited_commits: set[str] = set() - visited_trees: set[str] = set() - while commits_to_visit: - commit_hash = commits_to_visit.pop() - if commit_hash in visited_commits: - continue - visited_commits.add(commit_hash) - self._ensure_pulled_object(sock, buf, commit_hash, "commit") - commit = self.commits.read_commit(commit_hash) - commits_to_visit.extend(commit.parents) - self._pull_tree(sock, buf, commit.tree, visited_trees) + with self._session(host, port, token, "fetch") as (sock, buf): + tip = self._request_ref(sock, buf, branch) + if tip == "-": + raise NetworkProtocolError(f"remote has no branch {branch!r}") + self._fetch_history(sock, buf, tip) send_line(sock, "DONE") - except (OSError, ObjectNotFoundError, ObjectCorruptError) as exc: - raise NetworkProtocolError(f"pull from {host}:{port} failed: {exc}") from exc - finally: - sock.close() - self.commits.integrate_fetched_tip(branch, remote_hash) - print(f"{branch} now at {self.commits.read_ref(branch)}") + return tip + + def _fetch_history(self, sock, buf: bytearray, tip: str) -> None: + """Fetch the pinned graph once, checking each edge's expected object type.""" + seen: dict[str, str] = {} + pending = [(tip, "commit")] + while pending: + obj_hash, expected_type = pending.pop() + if obj_hash in seen: + if seen[obj_hash] != expected_type: + raise NetworkProtocolError( + f"object {obj_hash} is a {seen[obj_hash]}, expected a {expected_type}" + ) + continue + self._ensure_object(sock, buf, obj_hash, expected_type) + seen[obj_hash] = expected_type + if expected_type == "commit": + commit = self.commits.read_commit(obj_hash) + pending.extend((parent, "commit") for parent in commit.parents) + pending.append((commit.tree, "tree")) + elif expected_type == "tree": + pending.extend((entry.hash, entry.type) for entry in self.store.read_tree(obj_hash)) + + def _ensure_object(self, sock, buf: bytearray, obj_hash: str, expected_type: str) -> None: + """Request `obj_hash` unless it's already stored, and check it is an `expected_type`.""" + + if _has_object(self.store, obj_hash): + obj_type, _ = self.store.read_object(obj_hash) # re-hashes: catches local corruption + if obj_type != expected_type: + raise NetworkProtocolError( + f"object {obj_hash} is a {obj_type}, expected a {expected_type}" + ) + return - def _ensure_pulled_object( - self, sock, buf: bytearray, obj_hash: str, expected_type: str - ) -> None: - """Ensure an object exists locally and has its expected graph type.""" + send_line(sock, f"WANT {obj_hash}") + self._receive_object(sock, buf, obj_hash, expected_type) - try: - obj_type, _ = self.store.read_object(obj_hash) - except ObjectNotFoundError: - send_line(sock, f"WANT {obj_hash}") - self._receive_object(sock, buf, obj_hash) - obj_type, _ = self.store.read_object(obj_hash) + def pull(self, remote_address: str, branch: str, token: str) -> None: + """Fetch `branch` from the remote and integrate it into the local branch of that name. - if obj_type != expected_type: - raise ObjectCorruptError(obj_hash) + Integration is Module 3's job (`integrate_fetched_tip`): it runs + only after the fetched history is complete and validated, and a + `MergeConflictError` from it propagates unchanged so the user sees + the conflicting paths. + """ - def _pull_tree(self, sock, buf: bytearray, tree_hash: str, visited: set[str]) -> None: - """Fetch a tree and all nested trees and blobs, once each.""" + fetched = self.fetch(remote_address, branch, token) + before = self.commits.read_ref(branch) + merge_hash = self._integrate(branch, fetched) + after = self.commits.read_ref(branch) - if tree_hash in visited: - return - visited.add(tree_hash) - self._ensure_pulled_object(sock, buf, tree_hash, "tree") - for entry in self.store.read_tree(tree_hash): - if entry.type == "tree": - self._pull_tree(sock, buf, entry.hash, visited) - elif entry.type == "blob": - self._ensure_pulled_object(sock, buf, entry.hash, "blob") - else: - raise ObjectCorruptError(entry.hash) + if merge_hash is not None: + print(f"{branch}: merged {fetched[:7]} as {merge_hash[:7]}") + elif after == before: + print(f"{branch} is up to date") + else: + print(f"{branch} now at {after}") + + def _integrate(self, branch: str, fetched: str) -> str | None: + """Hand a validated tip to M3, preserving its merge and checkout errors.""" + return self.commits.integrate_fetched_tip(branch, fetched) def fetch_objects(self, remote_address: str, hashes: list[str], token: str) -> None: """Fetch each of `hashes` from the remote and write it into the local store. @@ -360,34 +427,21 @@ def fetch_objects(self, remote_address: str, hashes: list[str], token: str) -> N corrupted or mismatched reply never lands in the object store. """ - host, port = self._parse_address(remote_address) - if len(token) == 0: - raise NetworkProtocolError("fetch needs a token: pass --token") - - try: - sock = socket.create_connection((host, port), timeout=5) - except OSError as exc: - raise NetworkProtocolError(f"could not connect to {host}:{port}: {exc}") from exc - - try: - buf = bytearray() - send_line(sock, f"AUTH {token}") - reply = receive_line(sock, buf) - if reply != "OK": - raise NetworkProtocolError(f"auth failed: {reply}") - + host, port = self._preflight(remote_address, token, "fetch") + if any(not _valid_hash(obj_hash) for obj_hash in hashes): + raise NetworkProtocolError("invalid object hash") + with self._session(host, port, token, "fetch") as (sock, buf): for obj_hash in dict.fromkeys(hashes): send_line(sock, f"WANT {obj_hash}") self._receive_object(sock, buf, obj_hash) - send_line(sock, "DONE") - except OSError as exc: - raise NetworkProtocolError(f"connection to {host}:{port} failed: {exc}") from exc - finally: - sock.close() - def _receive_object(self, sock, buf: bytearray, expected_hash: str) -> None: - """Read one `OBJ`/`ERR` reply for `expected_hash` and store it if it checks out.""" + def _receive_object( + self, sock, buf: bytearray, expected_hash: str, expected_type: str | None = None + ) -> str: + """Read one `OBJ`/`ERR` reply for `expected_hash`, store it if it checks out, + and return its type. `expected_type`, when given, must match too. + """ header = receive_line(sock, buf) command, _, rest = header.partition(" ") @@ -399,15 +453,23 @@ def _receive_object(self, sock, buf: bytearray, expected_hash: str) -> None: obj_type, _, length_text = rest.partition(" ") if obj_type not in {"blob", "tree", "commit"} or not ( - length_text.isascii() and length_text.isdigit() + length_text.isascii() + and length_text.isdigit() + and len(length_text) <= 20 + and int(length_text) <= sys.maxsize ): raise NetworkProtocolError(f"malformed OBJ header: {header!r}") content = recv_exact(sock, buf, int(length_text)) if self.store.hash_object(content, obj_type) != expected_hash: raise NetworkProtocolError(f"object {expected_hash} failed hash verification") + if expected_type is not None and obj_type != expected_type: + raise NetworkProtocolError( + f"object {expected_hash} is a {obj_type}, expected a {expected_type}" + ) self.store.write_object(content, obj_type) + return obj_type def collect_reachable(self, branch: str) -> set[str]: """Return every commit, tree, and blob hash reachable from `branch`'s tip.""" @@ -467,6 +529,8 @@ def __init__( commits=None, conn_timeout=10.0, ): + if not token or any(ord(char) < 32 or ord(char) == 127 for char in token): + raise NetworkProtocolError("serve needs a nonempty token without control characters") self.repo_path = repo_path self.token = token self.host = host @@ -477,9 +541,13 @@ def __init__( ) self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self._sock.bind((host, port)) - self._sock.listen() + try: + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind((host, port)) + self._sock.listen() + except (OSError, OverflowError) as exc: + self._sock.close() + raise NetworkProtocolError(f"could not listen on {host}:{port}") from exc # accept() polls this instead of blocking forever: closing the socket # from another thread isn't guaranteed to unblock a pending accept() # on every platform (it does on macOS, not reliably on Linux). @@ -512,13 +580,22 @@ def serve_forever(self) -> None: send_line(conn, f"ERR object validation failed: {exc}") except OSError: pass - except (NetworkProtocolError, OSError): + except MiniGitError: + try: + send_line(conn, "ERR invalid repository state or protocol") + except OSError: + pass + except OSError: pass # a bad client must not take down the server finally: conn.close() def _handle_client(self, conn) -> None: - """Authenticate the connection, then answer REF / WANT requests until DONE.""" + """Authenticate the connection, then answer REF / REFS / WANT requests until DONE. + + Nothing is answered before a successful AUTH: any other first line + gets `ERR bad auth` and the connection is closed. + """ buf = bytearray() @@ -537,15 +614,35 @@ def _handle_client(self, conn) -> None: return elif command == "REF": self._send_ref(conn, value) + elif command == "REFS": + self._send_refs(conn) elif command == "WANT": self._send_object(conn, value) elif command == "PUSH": self._handle_push(conn, buf, value) return else: - send_line(conn, "ERR expected REF, WANT, PUSH, or DONE") + send_line(conn, "ERR expected REF, REFS, WANT, PUSH, or DONE") return + def _send_refs(self, conn) -> None: + """Reply with one `REF ` line per branch, then `END`. + + Branches whose name or contents wouldn't survive the wire format + (unsafe names, empty or malformed ref files) are left out. + """ + + for name in self.commits.list_branches(): + if not _valid_branch(name): + continue + try: + commit_hash = self.commits.read_ref(name) + except OSError: + continue # e.g. a directory under refs/heads/, not a ref file + if commit_hash and _valid_hash(commit_hash): + send_line(conn, f"REF {name} {commit_hash}") + send_line(conn, "END") + def _send_ref(self, conn, branch: str) -> None: """Reply with the commit hash `branch` currently points at, or `-` if it has none.""" @@ -645,7 +742,10 @@ def _receive_pushed_object(self, conn, buf: bytearray, expected_hash: str) -> bo obj_type, _, length_text = rest.partition(" ") if obj_type not in {"blob", "tree", "commit"} or not ( - length_text.isascii() and length_text.isdigit() + length_text.isascii() + and length_text.isdigit() + and len(length_text) <= 20 + and int(length_text) <= sys.maxsize ): send_line(conn, "ERR malformed OBJ header") return False @@ -708,6 +808,8 @@ def _send_object(self, conn, obj_hash: str) -> None: # # AUTH - client authenticates the connection with its token # REF - ask for / report the commit hash a branch currently points to +# REFS - list every branch: zero or more `REF `, then END +# END - terminates a REFS listing # WANT - request the object with this hash (fetch/pull) # PUSH - propose moving from ("-" = unborn) to # HAVE - ask whether the remote already has an object @@ -721,6 +823,13 @@ def _send_object(self, conn, obj_hash: str) -> None: # missing) -> DONE (remote validates the new history and moves the ref) -> # connection closes. # +# A fetch session: AUTH -> REF (pin the tip) -> WANT/OBJ for each commit, +# tree, and blob reachable from it that isn't already local -> DONE. Pull +# is a fetch followed by Module 3 integrating the tip into the local branch. +# Multi-branch transfers repeat the single-branch session per name from +# REFS (or per local branch for push); shared objects travel once because +# later sessions find them already present. +# # Conflict detection happens twice. The client refuses before PUSH if the # remote tip from REF isn't an ancestor of its local tip. The server # refuses PUSH if isn't its current ref, and again at DONE if the ref @@ -729,37 +838,98 @@ def _send_object(self, conn, obj_hash: str) -> None: def register_subcommands(subparsers) -> None: - """Register the `push`, `pull`, and `serve` subcommands with the CLI parser.""" + """Register the networking subcommands with the CLI parser.""" push_parser = subparsers.add_parser("push", help="push a branch to a remote") push_parser.add_argument("address") - push_parser.add_argument("branch") + push_parser.add_argument("branch", nargs="?") + push_parser.add_argument("--all", action="store_true", help="push every local branch") push_parser.add_argument("--token", default="") push_parser.set_defaults(handler=cmd_push) - pull_parser = subparsers.add_parser("pull", help="pull a branch to a local") + pull_parser = subparsers.add_parser("pull", help="fetch a branch and integrate it locally") pull_parser.add_argument("address") - pull_parser.add_argument("branch") + pull_parser.add_argument("branch", nargs="?") + pull_parser.add_argument("--all", action="store_true", help="pull every remote branch") pull_parser.add_argument("--token", default="") pull_parser.set_defaults(handler=cmd_pull) + fetch_parser = subparsers.add_parser("fetch", help="download a branch's history only") + fetch_parser.add_argument("address") + fetch_parser.add_argument("branch") + fetch_parser.add_argument("--token", default="") + fetch_parser.set_defaults(handler=cmd_fetch) + + ls_remote_parser = subparsers.add_parser("ls-remote", help="list a remote's branches") + ls_remote_parser.add_argument("address") + ls_remote_parser.add_argument("--token", default="") + ls_remote_parser.set_defaults(handler=cmd_ls_remote) + serve_parser = subparsers.add_parser("serve", help="serve this repo to push/pull clients") serve_parser.add_argument("--port", type=int, required=True) serve_parser.add_argument("--token", default="") serve_parser.set_defaults(handler=cmd_serve) +def _branches_from_args(args, all_branches) -> list[str]: + """Resolve `` vs `--all` into the list of branches to operate on.""" + + if args.all == (args.branch is not None): + raise MiniGitError("pass either a branch name or --all") + return sorted(all_branches()) if args.all else [args.branch] + + +def _run_each(operation, branches: list[str]) -> int: + """Run `operation(branch)` for each branch, reporting failures without stopping. + + A single branch's error propagates as usual. With several, each + failure is printed against its branch and the exit code is 1 if any + branch failed. + """ + + if len(branches) == 1: + operation(branches[0]) + return 0 + + failed = False + for branch in branches: + try: + operation(branch) + except MiniGitError as exc: + print(f"minigit: {branch}: {exc}", file=sys.stderr) + failed = True + return 1 if failed else 0 + + def cmd_push(args) -> int: """Handle `minigit push` from the CLI.""" - RemoteClient().push(args.address, args.branch, args.token) - return 0 + client = RemoteClient() + branches = _branches_from_args(args, client.commits.list_branches) + return _run_each(lambda branch: client.push(args.address, branch, args.token), branches) def cmd_pull(args) -> int: """Handle `minigit pull` from the CLI.""" - RemoteClient().pull(args.address, args.branch, args.token) + client = RemoteClient() + branches = _branches_from_args(args, lambda: client.list_refs(args.address, args.token)) + return _run_each(lambda branch: client.pull(args.address, branch, args.token), branches) + + +def cmd_fetch(args) -> int: + """Handle `minigit fetch` from the CLI.""" + + tip = RemoteClient().fetch(args.address, args.branch, args.token) + print(f"fetched {args.branch} at {tip}") + return 0 + + +def cmd_ls_remote(args) -> int: + """Handle `minigit ls-remote` from the CLI.""" + + for name, commit_hash in sorted(RemoteClient().list_refs(args.address, args.token).items()): + print(f"{commit_hash}\t{name}") return 0 @@ -768,5 +938,10 @@ def cmd_serve(args) -> int: server = RemoteServer(port=args.port, token=args.token) print(f"listening on {server.host}:{server.port}") - server.serve_forever() + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.close() return 0 diff --git a/tests/test_remote.py b/tests/test_remote.py index 895232a..8eb0265 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -9,7 +9,12 @@ from minigit.cli import main from minigit.commits import CommitManager -from minigit.errors import MiniGitError, NetworkProtocolError, ObjectNotFoundError +from minigit.errors import ( + MergeConflictError, + MiniGitError, + NetworkProtocolError, + ObjectNotFoundError, +) from minigit.objects import ObjectStore from minigit.objects import TreeEntry as RealTreeEntry from minigit.remote import RemoteClient, RemoteServer, receive_line, recv_exact, send_line @@ -1101,3 +1106,507 @@ def test_cli_push_rejection_shows_reason(push_pair, monkeypatch, capsys): assert "push rejected" in err assert "pull first" in err assert "tok" not in err + + +# --- fetch / pull / multi-ref (#41) --- + + +def _commit_on( + commits: CommitManager, branch: str, files: dict, parents: list, message: str +) -> str: + """Write a commit with fixed metadata straight onto `branch`, without touching HEAD.""" + + tree = _write_tree(commits.store, files) + lines = [f"tree {tree}", *(f"parent {p}" for p in parents)] + lines += ["author tester 0", "committer tester 0", "", message] + commit_hash = commits.store.write_object("\n".join(lines).encode(), "commit") + commits.write_ref(branch, commit_hash) + return commit_hash + + +class RecordingCommitManager(CommitManager): + """Run the real merge engine and record whether fetched history was complete.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.calls: list[tuple[str, str, bool]] = [] + + def integrate_fetched_tip(self, branch: str, fetched_hash: str) -> str | None: + self._validate_fetched_history(fetched_hash) + self.calls.append((branch, fetched_hash, True)) + return super().integrate_fetched_tip(branch, fetched_hash) + + +@dataclass +class PullPair: + client: RemoteClient + local: RecordingCommitManager + remote: CommitManager + address: str + local_root: Path + + +@pytest.fixture +def pull_pair(tmp_path, push_pair): + """Use the real commit manager in a fresh local repository.""" + + root = tmp_path / "puller" + root.mkdir() + store = ObjectStore(str(root)) + local = RecordingCommitManager(str(root), store=store) + client = RemoteClient(repo_path=str(root), store=store, commits=local) + return PullPair(client, local, push_pair.remote_commits, push_pair.address, root) + + +def _count_receives(monkeypatch) -> list: + """Record every object payload `RemoteClient` receives, while still receiving it.""" + + calls = [] + original = RemoteClient._receive_object + + def counting(self, sock, buf, expected_hash, expected_type=None): + calls.append(expected_hash) + return original(self, sock, buf, expected_hash, expected_type) + + monkeypatch.setattr(RemoteClient, "_receive_object", counting) + return calls + + +def _scripted_client(monkeypatch, tmp_path, script: bytes) -> RemoteClient: + """A real client whose socket replays `script` as the server's side of the session.""" + + class ScriptedSocket(FakeReceiveSocket): + def sendall(self, data): + pass + + def close(self): + pass + + sock = ScriptedSocket(script) + monkeypatch.setattr(socket, "create_connection", lambda *args, **kwargs: sock) + return RemoteClient(tmp_path) + + +def _merge_history(remote: CommitManager) -> tuple[str, str, str]: + """base <- left <- merge(left, base): a merge whose second parent is the common ancestor.""" + + files = {"a.txt": b"base\n", "bin/data.bin": bytes(range(256)), "d/e/f.txt": b"deep\n"} + base = _commit_on(remote, "main", files, [], "base") + left = _commit_on(remote, "main", {**files, "a.txt": b"left\n"}, [base], "left") + merge = _commit_on(remote, "main", {**files, "a.txt": b"merged\n"}, [left, base], "merge") + return base, left, merge + + +def test_fetch_downloads_full_history_without_touching_refs(push_pair): + pair = push_pair + base, left, merge = _merge_history(pair.remote_commits) + + assert pair.client.fetch(pair.address, "main", "tok") == merge + + remote_client = RemoteClient( + pair.server.repo_path, store=pair.remote_store, commits=pair.remote_commits + ) + expected = remote_client._collect_from(merge) + for obj_hash in expected: + assert pair.local_store.read_object(obj_hash) == pair.remote_store.read_object(obj_hash) + assert set(pair.local_commits.walk_history(merge)) == {base, left, merge} + assert pair.local_commits.read_ref("main") is None + assert not (Path(pair.client.repo_path) / ".minigit" / "HEAD").exists() + + +def test_repeat_fetch_receives_no_objects(push_pair, monkeypatch): + pair = push_pair + _merge_history(pair.remote_commits) + pair.client.fetch(pair.address, "main", "tok") + + received = _count_receives(monkeypatch) + pair.client.fetch(pair.address, "main", "tok") + assert received == [] + + +def test_fetch_requests_missing_tree_under_present_commit(push_pair, monkeypatch): + """An interrupted fetch can leave a commit stored without its tree.""" + + pair = push_pair + tip = _commit_on(pair.remote_commits, "main", {"a.txt": b"1"}, [], "c") + obj_type, content = pair.remote_store.read_object(tip) + pair.local_store.write_object(content, obj_type) # commit present, tree + blob missing + + received = _count_receives(monkeypatch) + pair.client.fetch(pair.address, "main", "tok") + tree = pair.remote_commits.read_commit(tip).tree + assert tip not in received + assert tree in received + assert len(received) == 2 + + +def test_fetch_shared_objects_across_branches_transfer_once(push_pair, monkeypatch): + pair = push_pair + shared = {"shared.bin": b"\x00" * 1000, "lib/util.txt": b"util\n"} + _commit_on(pair.remote_commits, "main", {**shared, "m.txt": b"m"}, [], "m") + _commit_on(pair.remote_commits, "feature", {**shared, "f.txt": b"f"}, [], "f") + + received = _count_receives(monkeypatch) + pair.client.fetch(pair.address, "main", "tok") + pair.client.fetch(pair.address, "feature", "tok") + assert len(received) == len(set(received)) + assert pair.local_store.hash_object(b"\x00" * 1000, "blob") in received + + +def test_fetch_missing_branch_raises(push_pair): + with pytest.raises(NetworkProtocolError, match="no branch 'nope'"): + push_pair.client.fetch(push_pair.address, "nope", "tok") + + +@pytest.mark.parametrize("branch", ["../escape", "a b", "", "x/../y"]) +def test_fetch_invalid_branch_name_raises(push_pair, branch): + with pytest.raises(NetworkProtocolError, match="invalid branch"): + push_pair.client.fetch(push_pair.address, branch, "tok") + + +def test_bad_token_is_rejected_and_never_echoed(push_pair): + _commit_on(push_pair.remote_commits, "main", {"a.txt": b"1"}, [], "c") + for call in ( + lambda: push_pair.client.fetch(push_pair.address, "main", "s3cret-xyz"), + lambda: push_pair.client.list_refs(push_pair.address, "s3cret-xyz"), + ): + with pytest.raises(NetworkProtocolError, match="authentication failed") as info: + call() + assert "s3cret" not in str(info.value) + + +def test_command_before_auth_is_refused_and_server_survives(push_pair): + pair = push_pair + for first_line in ["REF main", "REFS", f"WANT {'a' * 40}"]: + with socket.create_connection(("127.0.0.1", pair.server.port), timeout=2) as sock: + buf = bytearray() + send_line(sock, first_line) + assert receive_line(sock, buf) == "ERR bad auth" + assert pair.client.list_refs(pair.address, "tok") == {} + + +def _blob_hash(data: bytes) -> str: + return hashlib.sha1(f"blob {len(data)}".encode() + b"\0" + data).hexdigest() + + +@pytest.mark.parametrize( + "reply, message", + [ + (b"OBJ blob 1\nx", "is a blob, expected a commit"), + (b"OBJ commit 3\nabc", "failed hash verification"), + (b"OBJ commit 50\nabc", "connection closed"), + (b"ERR unknown object\n", "could not provide"), + (b"garbage\n", "expected OBJ"), + ], +) +def test_fetch_bad_object_replies_raise(monkeypatch, tmp_path, reply, message): + tip = _blob_hash(b"x") # a valid hash the scripted server lies about + script = b"OK\nREF main " + tip.encode() + b"\n" + reply + client = _scripted_client(monkeypatch, tmp_path, script) + with pytest.raises(NetworkProtocolError, match=message): + client.fetch("localhost:9418", "main", "tok") + assert not client.store.has_object(tip) + + +@pytest.mark.parametrize( + "script", + [ + b"OK\nREF main zzz\nEND\n", + b"OK\nREF ../x " + b"a" * 40 + b"\nEND\n", + b"OK\nREF main " + b"a" * 40 + b"\nREF main " + b"b" * 40 + b"\nEND\n", + b"OK\nERR expected REF, WANT, PUSH, or DONE\n", + b"OK\nREF main " + b"a" * 40 + b"\n", # no END before the connection closes + ], +) +def test_list_refs_malformed_replies_raise(monkeypatch, tmp_path, script): + client = _scripted_client(monkeypatch, tmp_path, script) + with pytest.raises(NetworkProtocolError): + client.list_refs("localhost:9418", "tok") + + +def test_list_refs_lists_every_branch_and_skips_non_refs(push_pair): + pair = push_pair + assert pair.client.list_refs(pair.address, "tok") == {} + main_tip = _commit_on(pair.remote_commits, "main", {"a.txt": b"1"}, [], "m") + feature_tip = _commit_on(pair.remote_commits, "feature", {"b.txt": b"2"}, [], "f") + (Path(pair.server.repo_path) / ".minigit" / "refs" / "heads" / "nested").mkdir() + + assert pair.client.list_refs(pair.address, "tok") == {"main": main_tip, "feature": feature_tip} + + +def test_interrupted_fetch_keeps_state_and_retry_completes(push_pair, monkeypatch): + pair = push_pair + _, _, merge = _merge_history(pair.remote_commits) + total = len( + RemoteClient( + pair.server.repo_path, store=pair.remote_store, commits=pair.remote_commits + )._collect_from(merge) + ) + + original = RemoteClient._receive_object + calls = [] + + def flaky(self, sock, buf, expected_hash, expected_type=None): + if len(calls) == 3: + raise NetworkProtocolError("connection dropped") + calls.append(expected_hash) + return original(self, sock, buf, expected_hash, expected_type) + + monkeypatch.setattr(RemoteClient, "_receive_object", flaky) + with pytest.raises(NetworkProtocolError): + pair.client.fetch(pair.address, "main", "tok") + assert pair.local_commits.read_ref("main") is None + minigit_dir = Path(pair.client.repo_path) / ".minigit" + assert not (minigit_dir / "HEAD").exists() and not (minigit_dir / "index").exists() + + monkeypatch.setattr(RemoteClient, "_receive_object", original) + received = _count_receives(monkeypatch) + assert pair.client.fetch(pair.address, "main", "tok") == merge + assert len(received) == total - 3 + + +def test_pull_unborn_then_fast_forward_checks_out_files(pull_pair, capsys): + pair = pull_pair + base = _commit_on(pair.remote, "main", {"a.txt": b"one\n"}, [], "base") + pair.client.pull(pair.address, "main", "tok") + assert pair.local.read_ref("main") == base + assert (pair.local_root / "a.txt").read_bytes() == b"one\n" + + ahead = _commit_on(pair.remote, "main", {"a.txt": b"two\n"}, [base], "ahead") + capsys.readouterr() + pair.client.pull(pair.address, "main", "tok") + assert pair.local.read_ref("main") == ahead + assert (pair.local_root / "a.txt").read_bytes() == b"two\n" + assert f"main now at {ahead}" in capsys.readouterr().out + assert pair.local.calls == [("main", base, True), ("main", ahead, True)] + + +def test_pull_up_to_date_and_local_ahead_change_nothing(pull_pair, capsys, monkeypatch): + pair = pull_pair + base = _commit_on(pair.remote, "main", {"a.txt": b"one\n"}, [], "base") + pair.client.pull(pair.address, "main", "tok") + + received = _count_receives(monkeypatch) + capsys.readouterr() + pair.client.pull(pair.address, "main", "tok") + assert "main is up to date" in capsys.readouterr().out + assert received == [] + + local_tip = _commit_on(pair.local, "main", {"a.txt": b"local\n"}, [base], "local") + pair.client.pull(pair.address, "main", "tok") + assert pair.local.read_ref("main") == local_tip + assert "main is up to date" in capsys.readouterr().out + + +def test_pull_diverged_reports_merge_commit(pull_pair, capsys): + pair = pull_pair + base = _commit_on(pair.remote, "main", {"a.txt": b"base\n"}, [], "base") + pair.client.pull(pair.address, "main", "tok") + local_tip = _commit_on(pair.local, "main", {"a.txt": b"base\n", "l.txt": b"l"}, [base], "l") + remote_tip = _commit_on(pair.remote, "main", {"a.txt": b"base\n", "r.txt": b"r"}, [base], "r") + pair.local.tree.checkout(pair.local.read_commit(local_tip).tree) + + capsys.readouterr() + pair.client.pull(pair.address, "main", "tok") + merge = pair.local.read_ref("main") + assert pair.local.read_commit(merge).parents == [local_tip, remote_tip] + assert f"main: merged {remote_tip[:7]} as {merge[:7]}" in capsys.readouterr().out + + +def test_pull_conflict_propagates_paths_and_keeps_ref(pull_pair): + pair = pull_pair + base = _commit_on(pair.remote, "main", {"a.txt": b"base\n"}, [], "base") + pair.client.pull(pair.address, "main", "tok") + local_tip = _commit_on(pair.local, "main", {"a.txt": b"ours\n"}, [base], "ours") + _commit_on(pair.remote, "main", {"a.txt": b"theirs\n"}, [base], "theirs") + pair.local.tree.checkout(pair.local.read_commit(local_tip).tree) + + with pytest.raises(MergeConflictError) as info: + pair.client.pull(pair.address, "main", "tok") + assert info.value.paths == ["a.txt"] + assert pair.local.read_ref("main") == local_tip + + +def test_pull_never_integrates_after_failed_fetch(pull_pair): + pair = pull_pair + tip = _commit_on(pair.remote, "main", {"a.txt": b"1"}, [], "c") + blob = pair.remote.store.hash_object(b"1", "blob") + pair.remote.store._object_path(blob).write_bytes(b"corrupt") + + with pytest.raises(NetworkProtocolError, match="corrupt object"): + pair.client.pull(pair.address, "main", "tok") + assert pair.local.calls == [] + assert pair.local.read_ref("main") is None + assert pair.local.store.has_object(tip) # complete objects kept for a retry + + +def test_pull_creates_and_fast_forwards_inactive_branch(push_pair): + pair = push_pair + f1 = _commit_on(pair.remote_commits, "feature", {"f.txt": b"1"}, [], "f1") + pair.client.pull(pair.address, "feature", "tok") + assert pair.local_commits.read_ref("feature") == f1 + + f2 = _commit_on(pair.remote_commits, "feature", {"f.txt": b"2"}, [f1], "f2") + pair.client.pull(pair.address, "feature", "tok") + assert pair.local_commits.read_ref("feature") == f2 + assert not (Path(pair.client.repo_path) / "f.txt").exists() # no checkout + + +def test_pull_rejects_diverged_inactive_branch(push_pair): + pair = push_pair + base = _commit_on(pair.remote_commits, "feature", {"f.txt": b"base"}, [], "base") + pair.client.pull(pair.address, "feature", "tok") + local_tip = _commit_on(pair.local_commits, "feature", {"f.txt": b"ours"}, [base], "ours") + _commit_on(pair.remote_commits, "feature", {"f.txt": b"theirs"}, [base], "theirs") + + with pytest.raises(MiniGitError, match="check out that branch and pull again"): + pair.client.pull(pair.address, "feature", "tok") + assert pair.local_commits.read_ref("feature") == local_tip + + +def test_pull_into_current_branch_uses_real_integration(push_pair): + pair = push_pair + tip = _commit_on(pair.remote_commits, "main", {"a.txt": b"1"}, [], "c") + pair.client.pull(pair.address, "main", "tok") + assert pair.local_commits.read_ref("main") == tip + assert (Path(pair.client.repo_path) / "a.txt").read_bytes() == b"1" + + +def test_cli_ls_remote_and_push_all(push_pair, monkeypatch, capsys): + pair = push_pair + main_tip = _commit_on(pair.local_commits, "main", {"a.txt": b"1"}, [], "m") + feature_tip = _commit_on(pair.local_commits, "feature", {"b.txt": b"2"}, [main_tip], "f") + monkeypatch.chdir(pair.client.repo_path) + + assert main(["push", pair.address, "--all", "--token", "tok"]) == 0 + assert pair.remote_commits.read_ref("main") == main_tip + assert pair.remote_commits.read_ref("feature") == feature_tip + + capsys.readouterr() + assert main(["ls-remote", pair.address, "--token", "tok"]) == 0 + assert capsys.readouterr().out == f"{feature_tip}\tfeature\n{main_tip}\tmain\n" + + +def test_cli_pull_all_reports_each_branch(push_pair, monkeypatch, capsys): + pair = push_pair + _commit_on(pair.remote_commits, "main", {"a.txt": b"1"}, [], "m") + feature_tip = _commit_on(pair.remote_commits, "feature", {"b.txt": b"2"}, [], "f") + monkeypatch.chdir(pair.client.repo_path) + + assert main(["pull", pair.address, "--all", "--token", "tok"]) == 0 + out, err = capsys.readouterr() + assert pair.local_commits.read_ref("feature") == feature_tip + assert f"feature now at {feature_tip}" in out + assert pair.local_commits.read_ref("main") == pair.remote_commits.read_ref("main") + assert (Path(pair.client.repo_path) / "a.txt").read_bytes() == b"1" + assert err == "" + + +@pytest.mark.parametrize("args", [["push", "--all", "main"], ["push"], ["pull"]]) +def test_cli_branch_or_all_required(push_pair, monkeypatch, capsys, args): + monkeypatch.chdir(push_pair.client.repo_path) + command, *rest = args + assert main([command, push_pair.address, *rest, "--token", "tok"]) == 1 + assert "either a branch name or --all" in capsys.readouterr().err + + +@pytest.mark.parametrize("port", ["²", "9" * 5000]) +def test_non_ascii_and_oversized_ports_raise_protocol_errors(port): + with pytest.raises(NetworkProtocolError): + make_client()._parse_address(f"localhost:{port}") + + +@pytest.mark.parametrize("token", ["", "secret\nREFS", "secret\0", "secret\t"]) +def test_server_rejects_unusable_tokens_before_listening(tmp_path, token): + with pytest.raises(NetworkProtocolError): + RemoteServer(tmp_path, token=token) + + +def test_client_rejects_control_characters_without_echoing_token(tmp_path): + token = "private-demo-token\nREFS" + with pytest.raises(NetworkProtocolError) as info: + RemoteClient(tmp_path).list_refs("localhost:9418", token) + assert "private-demo-token" not in str(info.value) + + +def test_server_reply_cannot_echo_token_into_client_error(monkeypatch, tmp_path): + token = "private-demo-token" + client = _scripted_client(monkeypatch, tmp_path, f"OK\nERR {token}\n".encode()) + with pytest.raises(NetworkProtocolError) as info: + client.fetch("localhost:9418", "main", token) + assert token not in str(info.value) + + +@pytest.mark.parametrize("ending", [b"", b"\n"]) +def test_oversized_protocol_line_is_rejected(ending): + from minigit.remote import MAX_PROTOCOL_LINE_BYTES + + sock = FakeReceiveSocket(b"x" * (MAX_PROTOCOL_LINE_BYTES + 1) + ending) + with pytest.raises(NetworkProtocolError, match="too long"): + receive_line(sock, bytearray()) + + +def test_fetch_rejects_one_hash_used_as_both_tree_and_parent(push_pair): + pair = push_pair + tree = pair.remote_store.write_tree([]) + body = pair.remote_commits._format_commit(tree, [tree], "Test ", "bad parent") + tip = pair.remote_store.write_object(body.encode(), "commit") + pair.remote_commits.write_ref("main", tip) + with pytest.raises(NetworkProtocolError, match="expected a commit"): + pair.client.fetch(pair.address, "main", "tok") + assert pair.local_commits.read_ref("main") is None + + +def test_nested_branches_round_trip_and_share_objects(push_pair, monkeypatch): + pair = push_pair + tip = _commit(pair.local_store, pair.local_commits, {"shared": b"content"}, []) + pair.local_commits.create_branch("feature/topic", tip) + pair.client.push(pair.address, "main", "tok") + pair.client.push(pair.address, "feature/topic", "tok") + assert pair.client.list_refs(pair.address, "tok") == {"feature/topic": tip, "main": tip} + received = _count_receives(monkeypatch) + pair.client.pull(pair.address, "feature/topic", "tok") + assert received == [] + assert pair.local_commits.read_ref("feature/topic") == tip + + +@pytest.mark.parametrize("ref_request", ["REF feature", "REF escape/topic", "REF bad\tname"]) +def test_bad_ref_session_does_not_stop_server(push_pair, ref_request): + pair = push_pair + tip = _commit(pair.remote_store, pair.remote_commits, {}, []) + pair.remote_commits.create_branch("feature/topic", tip) + refs = Path(pair.server.repo_path) / ".minigit/refs/heads" + (refs / "escape").symlink_to(refs / "feature", target_is_directory=True) + with socket.create_connection(("127.0.0.1", pair.server.port), timeout=2) as sock: + buf = bytearray() + send_line(sock, "AUTH tok") + assert receive_line(sock, buf) == "OK" + send_line(sock, ref_request) + assert receive_line(sock, buf).startswith("ERR ") + assert pair.client.list_refs(pair.address, "tok") == {"feature/topic": tip, "main": tip} + + +def test_oversized_object_length_is_rejected_and_next_client_succeeds(push_pair): + pair = push_pair + with socket.create_connection(("127.0.0.1", pair.server.port), timeout=2) as sock: + buf = bytearray() + send_line(sock, "AUTH tok") + assert receive_line(sock, buf) == "OK" + send_line(sock, f"PUSH main - {KNOWN_HASH}") + assert receive_line(sock, buf) == "OK" + send_line(sock, f"PUT {KNOWN_HASH}") + send_line(sock, "OBJ blob " + "9" * 5000) + assert receive_line(sock, buf) == "ERR malformed OBJ header" + assert pair.remote_commits.read_ref("main") is None + assert pair.client.list_refs(pair.address, "tok") == {} + + +def test_fetch_rejects_oversized_object_length(monkeypatch, tmp_path): + client = _scripted_client( + monkeypatch, + tmp_path, + f"OK\nREF main {KNOWN_HASH}\nOBJ commit {'9' * 5000}\n".encode(), + ) + with pytest.raises(NetworkProtocolError, match="malformed OBJ header"): + client.fetch("localhost:9418", "main", "tok") + assert not client.store.has_object(KNOWN_HASH) diff --git a/tests/test_week6_integration.py b/tests/test_week6_integration.py new file mode 100644 index 0000000..40154d3 --- /dev/null +++ b/tests/test_week6_integration.py @@ -0,0 +1,139 @@ +"""Week 6 checkpoint: complete branch exchange across fresh CLI processes.""" + +import os +import subprocess +import sys +import threading +from pathlib import Path + +import pytest + +from minigit.commits import CommitManager +from minigit.remote import RemoteServer + + +@pytest.fixture +def server(tmp_path): + remote = tmp_path / "remote" + remote.mkdir() + server = RemoteServer(remote, token="test-token") + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield server + server.close() + thread.join(timeout=2) + assert not thread.is_alive() + + +def _run(repo, *argv) -> int: + """Run a fresh CLI process with bounded runtime and forward its output.""" + env = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1])} + result = subprocess.run( + [sys.executable, "-m", "minigit.cli", *argv], + cwd=repo, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + print(result.stdout, end="") + print(result.stderr, end="", file=sys.stderr) + return result.returncode + + +def _commit_file(monkeypatch, repo, path, text, message) -> str: + (repo / path).write_text(text) + assert _run(repo, "add", path) == 0 + assert _run(repo, "commit", "-m", message) == 0 + manager = CommitManager(repo) + return manager.read_ref(manager.read_head()) + + +def _repo_with_merge_commit(monkeypatch, root): + """main and feature diverge from a base, then main records a two-parent merge.""" + + root.mkdir() + assert _run(root, "init") == 0 + base = _commit_file(monkeypatch, root, "a.txt", "line\n", "base") + assert _run(root, "branch", "feature") == 0 + assert _run(root, "checkout", "feature") == 0 + feature = _commit_file(monkeypatch, root, "f.txt", "feature\n", "feature") + assert _run(root, "checkout", "main") == 0 + main_tip = _commit_file(monkeypatch, root, "a.txt", "main\n", "main") + + assert _run(root, "merge", "feature") == 0 + manager = CommitManager(root) + merge = manager.read_ref("main") + assert manager.read_commit(merge).parents == [main_tip, feature] + return base, feature, main_tip, merge + + +def test_two_repos_exchange_branches_and_merge_commit(tmp_path, monkeypatch, capsys, server): + address = f"127.0.0.1:{server.port}" + a, b = tmp_path / "a", tmp_path / "b" + _, feature, main_tip, merge = _repo_with_merge_commit(monkeypatch, a) + + assert _run(a, "push", address, "--all", "--token", "test-token") == 0 + + b.mkdir() + assert _run(b, "init") == 0 + capsys.readouterr() + assert _run(b, "ls-remote", address, "--token", "test-token") == 0 + assert capsys.readouterr().out == f"{feature}\tfeature\n{merge}\tmain\n" + + # feature isn't checked out in B, so it's imported by ref alone. + assert _run(b, "pull", address, "feature", "--token", "test-token") == 0 + assert CommitManager(b).read_ref("feature") == feature + capsys.readouterr() + assert _run(b, "pull", address, "feature", "--token", "test-token") == 0 + assert "feature is up to date" in capsys.readouterr().out + + # The merge commit's full history (both parents) arrives intact. + assert _run(b, "fetch", address, "main", "--token", "test-token") == 0 + receiver = CommitManager(b) + assert receiver.read_commit(merge).parents == [main_tip, feature] + assert set(receiver.walk_history(merge)) == set(CommitManager(a).walk_history(merge)) + + # A and B both commit on feature; A pushes first, so B's push is rejected. + assert _run(b, "checkout", "feature") == 0 + assert (b / "f.txt").read_text() == "feature\n" + _commit_file(monkeypatch, b, "f.txt", "from b\n", "b") + assert _run(a, "checkout", "feature") == 0 + a_feature = _commit_file(monkeypatch, a, "f.txt", "from a\n", "a") + assert _run(a, "push", address, "feature", "--token", "test-token") == 0 + + capsys.readouterr() + assert _run(b, "push", address, "feature", "--token", "test-token") == 1 + assert "pull first" in capsys.readouterr().err + assert CommitManager(server.repo_path).read_ref("feature") == a_feature + + +def test_pull_into_checked_out_branch_and_resolve_conflict(tmp_path, monkeypatch, server): + address = f"127.0.0.1:{server.port}" + a, b = tmp_path / "a", tmp_path / "b" + _, _, _, merge = _repo_with_merge_commit(monkeypatch, a) + assert _run(a, "push", address, "main", "--token", "test-token") == 0 + + b.mkdir() + assert _run(b, "init") == 0 + assert _run(b, "pull", address, "main", "--token", "test-token") == 0 + assert CommitManager(b).read_ref("main") == merge + assert (b / "a.txt").read_text() == "main\n" + + # Same line changed on both sides -> pull reports the conflict. + a_tip = _commit_file(monkeypatch, a, "a.txt", "from a\n", "a") + assert _run(a, "push", address, "main", "--token", "test-token") == 0 + b_tip = _commit_file(monkeypatch, b, "a.txt", "from b\n", "b") + assert _run(b, "pull", address, "main", "--token", "test-token") == 1 + assert "<<<<<<<" in (b / "a.txt").read_text() + + # Resolve -> add -> commit, across fresh CLI calls, yields a two-parent commit. + resolved = _commit_file(monkeypatch, b, "a.txt", "resolved\n", "resolve") + assert CommitManager(b).read_commit(resolved).parents == [b_tip, a_tip] + assert _run(b, "push", address, "main", "--token", "test-token") == 0 + + assert _run(a, "pull", address, "main", "--token", "test-token") == 0 + assert CommitManager(a).read_ref("main") == resolved + assert (a / "a.txt").read_text() == "resolved\n" + assert _run(a, "status") == 0 + assert _run(b, "status") == 0