Skip to content

Add 'land.style' option. - #102

Merged
ZolotukhinM merged 1 commit into
mainfrom
ZolotukhinM/stack/1
Nov 16, 2025
Merged

ZolotukhinM merged 1 commit into
mainfrom
ZolotukhinM/stack/1

Conversation

@ZolotukhinM

@ZolotukhinM ZolotukhinM commented Nov 16, 2025 •

Copy link
Copy Markdown
Collaborator

Stacked PRs:


Add 'land.style' option.

The option can be used to disable 'land' command entirely, or use the
currently implemented 'bottom-only' style of landing. In future we might
implement other styles, such as "one-by-one" and "rebase-and-land".

The option can be used to disable 'land' command entirely, or use the
currently implemented 'bottom-only' style of landing. In future we might
implement other styles, such as "one-by-one" and "rebase-and-land".

stack-info: PR: #102, branch: ZolotukhinM/stack/1
@ZolotukhinM
ZolotukhinM merged commit a4dc185 into main Nov 16, 2025
2 checks passed
@ZolotukhinM
ZolotukhinM deleted the ZolotukhinM/stack/1 branch November 16, 2025 21:41
micahjsmith added a commit to micahjsmith/stack-pr that referenced this pull request Oct 12, 2026
…stead of patches (#74)

## Summary

Phase 6 of the autoland step-abstraction stack. `Services` becomes the real seam: everything a plan's execution does goes through the `Services` it is given, and the autoland tests inject fakes there instead of patching module attributes.

**What now takes `Services`**
- `waits.py`: `wait_for_approval`, `wait_for_checks`, `wait_for_mergeable`, `enqueue_and_wait`, `wait_for_workflow`, and their helpers (`_poll_read`, `_refresh_review`, `_rewait_after_retry`) take a keyword `services` and use its `github`, `ui`, and `sleep` instead of `gh.github`, `runtime.console`, and `runtime.resilient_sleep`. `_Ancestry(common, services)` asks `services.repo` for local ancestry and the fetch, and `services.github` for the compare API.
- `native_stack.py`: `land_as_native_stack(..., services=)`, `_native_stack_for`, and `_await_native_stack_merge` use `services.github`, `services.ui`, and `services.sleep`. The waits before a stack merge go through `services.waits`, and the landed-SHA lookup and rebase through `services.repo`. `StackMergeUnit` passes `uc.services`.
- New `autoland/repo.py`: `GitRepo(github, ui)`, the production `Repo`. It holds what used to be `waits.rebase_and_resubmit`, `waits._refresh_last_landed_sha` (now `landed_sha`, which returns the SHA instead of writing it into a context), and `waits._local_is_ancestor` (now `is_ancestor`), plus `fetch_target`. The commands they run are unchanged. `Repo` gains `fetch_target` and `is_ancestor`.
- `engine.print_execution_preview` takes the `ui` to print to.
- `commands._run_fresh`, `_run_resume`, and `_replan` take an optional keyword `services` (default `Services.production()`), which they use for the preview and pass to `_land`. This is the composition root, and tests inject there instead of patching `Services.production`.

**The `Services` shape**: a frozen dataclass of `github: gh.GitHub`, `ui: UI`, `sleep: Callable[[int], float]`, `repo: Repo`, and `waits: Waits`. `waits` defaults to `PollingWaits(self)`, the real wait loops polling through the same services. It always rebinds to the `Services` it belongs to, so `dataclasses.replace(services, github=...)` gives waits that poll the new client. `Waits` stays a service (now with `wait_for_mergeable`, which the stack merge needs) because it is still the right seam for step-level and stack-merge tests: they check what a step or a unit does when a wait succeeds or fails, without scripting a GitHub to get there.

**`Services.production()` binds the real objects directly**: `gh.github`, `ConsoleUI(runtime.console)`, `runtime.resilient_sleep`, and `GitRepo(gh.github, ui)`, as they are when it's called. The call-time delegates (`_LiveGitHub`, `_ModuleWaits`, `_GitRepo`, `_resilient_sleep`) are gone.

**Module singletons that remain, and why**
- `runtime.console`: the command flows' own output and prompts (`commands`), `--status` (`status`, `display.print_status`), plan file/editor messages (`plan`), worktree messages, and the shell runner, which echoes each command (`$ gh ...`) from inside `runtime.run`. The real `gh.GitHub` client runs through `runtime.run`, so its echo stays there as well. `resilient_sleep` also prints its wake-up notice there.
- `gh.github`: plan parsing (`plan.parse_plan`'s merged-PR check, PR-URL resolution in `steps/land.py`) and stack discovery (`discovery.enrich_stack`). All of these run before a plan executes.
- `runtime.resilient_sleep`, `runtime.HAVE_RICH`, `display.GLYPHS`: production defaults and display settings. The golden harness patches these, and `Services.production()` picks up the patched sleep and console when the run starts.

**Test fakes** (new `tests/autoland_fakes.py`), consolidating the fakes that were spread over four files:
- `FakeUI` (scripted answers, recorded output), and `FakeWaits` (outcome functions per wait; records `calls` and `entries`, `waited(kind)`, the workflow steps and target SHAs).
- `FakeRepo`: landed SHAs from a table, counted rebases (optionally failing) and fetches, plus known ancestry and what a fetch adds to it.
- `FakeGitHub`: an in-memory GitHub (PR states, reviews, checks, merge commits, the merge queue, workflow runs, compare results, GitHub stacks), merging `_FakeNativeStackGitHub` from `test_autoland.py`. It logs every call (`calls`, `called(method)`), and `script(method, *replies)` answers the next calls with values, exceptions, or callables before it goes back to its state.
- `ForbiddenGitHub` / `forbidden_sleep`, for tests where a step must not use them directly.
- `FakeSleep(limit=None)`: records sleeps, and after `limit` of them raises `StopPolling` to end a wait that is still waiting.
- `FakeServices`: a dataclass of the fakes, with `.services`. `FakeServices()` uses `FakeWaits`, and `FakeServices.polling(...)` runs the real wait loops over the fakes. `github` can also be a real `gh.GitHub()` for tests that fake `gh` at the subprocess boundary.
- `OPTS` and `stack()` move there too.

**Patch counts** (lines with `mocker.patch` / `mock.patch` / `monkeypatch.setattr`, before → after):
- `tests/test_autoland.py`: 102 → 34. What remains: the subprocess boundary (`shell_commands.run_with_retry` via one `_shell` helper, `subprocess.run`, `RETRY_DELAY`, `stack_pr.git.run_shell_command`), stack-pr cli functions (`deduce_base`, `command_submit`, `get_stack`, `get_current_branch_name`), plan parsing and discovery outside the injected path (`gh.github.owner_repo` / `pr_state`, `discovery.*`), the command-flow seams (`commands.Worktree` / `AutolandLock` / `_unpushed_changes`, `engine.execute_plan`), and console/display singletons (`runtime.console`, `HAVE_RICH`, `display.GLYPHS`, `sys.stdout`).
- `tests/test_autoland_interrupt.py`: 9 → 7. `Services.production` is no longer patched, in the tests or in the subprocess script. The git/cli stubs, `enrich_stack`, and `Worktree` remain.
- `tests/test_autoland_optimize.py`: 4 → 0. `StackMergeUnit` tests now run the real `land_as_native_stack` against `FakeServices`. The preview tests print to a `FakeUI`.
- `tests/test_autoland_step_execution.py`: 0 → 0. Its fakes moved to `autoland_fakes.py`.
- `tests/autoland_scenarios.py` (golden harness): 12 → 12, unchanged. It still fakes at `run_shell_command` / `get_command_output`, so the real client runs.

**How the migrated tests changed**, each checking the same behavior:
- Wait-loop tests run the real loops through `FakeServices.polling()`: workflow ancestry (local, after a fetch, then the compare API; verdict caching), approval by review decision, and failed/incomplete runs. They no longer patch `gh.github.*`, `runtime.run`, or `runtime.resilient_sleep`.
- The failed-read tests still use the real `gh.GitHub()` with `gh` faked at the subprocess boundary. They no longer patch the sleep. The two merge-queue ones script the mergeable read and the enqueue instead of patching `waits.wait_for_mergeable` and `gh.github.enqueue`.
- The native-stack engine tests run `engine.execute_plan` with `FakeGitHub` and `FakeWaits` (their one-at-a-time landing recorded as `queue` waits, and the rebases counted on `FakeRepo`), instead of 11 patches.
- `GitHub.poll_merge` tests use a small subclass of the real client with a canned PR state, instead of patching the singleton's methods.
- `StackMergeUnit` tests: "merged PRs" is now the stack-creation requests `FakeGitHub` received. A fall-through is a stack that can't be created. The stack-merge abort is a real rebase failure ("Rebase failed after merging modular#102: boom", so the test stack has a third PR above the run). "Not stack-merged" is checked as no GitHub calls and no output, instead of `assert_not_called` on a patched function.

No user-visible change. Console output, checkpoints, and status output are byte-for-byte identical.

## Test plan

- [x] The golden autoland scenarios pass with no golden files regenerated.
- [x] Every migrated test checks the same behavior through injected fakes, and each autoland test file passes on its own.
- [x] New: `dataclasses.replace` of a `Services`'s `github` gives waits that poll the new client and never the old one.
- [x] The full suite passes with an empty `GH_CONFIG_DIR` and `GH_TOKEN` unset.
- [ ] Manual: run a real `stack-pr autoland` (with and without `--merge-as-stack`) on a small stack and check that the output and the `--status` report are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


stack-info: PR: #74, branch: micahjsmith/stack/8

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant