Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions Documentation/GitWorkflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Git Workflow — Feature Branch with Squash Merge

This guide describes the full lifecycle of a feature branch: from creation to a merged PR and optional version tag. All steps are performed in the terminal.

---

## 1. Start from a clean main

```bash
git checkout main
git pull
```

---

## 2. Create a feature branch

Name branches with a short, descriptive slug.

```bash
git checkout -b feature/my-feature
```

---

## 3. Do the work — commit freely

Commit as often as you like. At this stage, commit messages can be rough.

```bash
git add -p # or: git add <file>
git commit -m "wip: rough first pass"

git add -p
git commit -m "fix: edge case in update"

git add -p
git commit -m "test: add test for edge case"
```

---

## 4. Keep the branch up to date with main

Do this regularly while working, and always before opening a PR. Rebase (not merge) keeps the history linear.

```bash
git fetch origin
git rebase origin/main
```

If there are conflicts, resolve each file, then continue:

```bash
# resolve conflicts in editor, then:
git add <resolved-file>
git rebase --continue
```

---

## 5. Clean up commit history before the PR

Since you use **squash merge**, GitHub will collapse everything into one commit anyway. Still, cleaning up your branch makes review easier and produces a better squash commit message on `main`.

Use interactive rebase to squash, reword, or drop commits:

```bash
git rebase -i origin/main
```

In the editor, mark commits as `pick`, `squash` (`s`), `fixup` (`f`), or `reword` (`r`). A typical result is one or two clean, descriptive commits:

```
pick abc1234 Add SPI badges and fix docs URL
pick def5678 Harden test helper with timeout
```

After saving, Git opens another editor for the combined commit message if you squashed. Write a clear, imperative-mood summary:

```
Add SPI badges, fix docs URL, and harden test helper with timeout

- README: add Swift Package Index badges for versions and platforms
- SwiftUIFirst.md: fix package URL (your-org → couchdeveloper)
- Tests: convert embedInWindowAndMakeKey to a throwing function with
a configurable timeout; remove redundant readyExpectation pattern
```

---

## 6. Push the branch

If you have previously pushed and then rebased (which rewrites history), use `--force-with-lease`. This is safer than `--force` — it will refuse to push if someone else has pushed to the branch since your last fetch.

```bash
git push origin feature/my-feature # first push
git push --force-with-lease origin feature/my-feature # after a rebase
```

---

## 7. Open a Pull Request

```bash
open https://github.com/couchdeveloper/EffectView/compare/main...feature/my-feature?expand=1
```

Fill in title and body, then click **Create pull request**.

> Make sure the merge strategy on GitHub is set to **Squash and merge**.

---

## 8. After the PR is merged

Switch to `main` and pull. After the pull, `HEAD` is exactly the squash commit that was just merged — this is the right moment to inspect and tag.

```bash
git checkout main
git pull
```

---

## 9. Tag the version (if applicable)

Tag **immediately after `git pull`** while `HEAD` is still the squash commit. This guarantees the tag points to the correct commit.

First, confirm only your squash commit sits above the previous tag — this also tells you what the previous version was:

```bash
git describe --tags --abbrev=0
# → e.g. 0.1.0 (the last released version)

git log --oneline $(git describe --tags --abbrev=0)..HEAD
# Expected output — exactly one commit, yours:
# 55cb803 (HEAD -> main, origin/main) Add SPI badges, fix docs URL, and harden test helper with timeout
```

If more than one commit appears, a previous merge has not been tagged yet. **Stop — determine and apply that tag first** before deciding your own version number. The version sequence must be settled in order.

Then create an annotated tag and push it. Annotated tags (not lightweight) record the tagger, date, and message — they are what `git describe` and GitHub Releases use.

```bash
git tag -a 0.2.0 -m "Release 0.2.0"
git push origin 0.2.0
```

Verify the tag landed on the right commit:

```bash
git show 0.2.0 --stat
# Should show the squash commit hash and the changed files
```

---

## 10. Clean up branches

Now that `main` is tagged, delete the feature branch. Because squash merge creates a new commit with no parent pointer back to the feature branch, Git requires a force-delete — this is expected, not a warning to worry about.

```bash
git branch -D feature/my-feature # local
git push origin --delete feature/my-feature # remote
```
51 changes: 44 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,38 @@
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fcouchdeveloper%2FEffectView%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/couchdeveloper/EffectView)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fcouchdeveloper%2FEffectView%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/couchdeveloper/EffectView)

A small SwiftUI helper for Elm-style event handling with explicit side effects.
A concrete SwiftUI pattern for state, events, and async effects — without an `@Observable` class, without scattered ad-hoc methods, favouring an event-driven, MVI-style design.

- Single mutation point via `update`.
- Explicit effects (`task`, `action`, `cancel`).
- Optional dependency environment captured for the view lifetime.

## Core principles and benefits
## The problem with the conventional approach

- Clear separation between pure state updates and side effects.
- Effects are explicit, managed, and cancellable.
- Deterministic update loop with a single mutation point.
- Great testability: update logic can be exercised in isolation.
In a typical SwiftUI view backed by an `@Observable` ViewModel, state is mutated from many places — `onAppear`, button handlers, async task completions, timers. As the view grows:

- Two tasks can race to update the same property.
- An `isLoading` flag gets set to `false` before a second request finishes.
- A cancelled task still calls back and overwrites fresh state.
- Testing requires constructing the whole ViewModel and observing side effects.

None of these are bugs you wrote on purpose. They're structural: there's no single, authoritative place that says "given this state and this event, here is the new state".

EffectView gives you that place.

## What you get

- **One transition function owns all state changes.** `update` takes the current state and an event, and returns new state plus an optional effect — no `async`, no network calls inside it, just logic. The same event on the same state always produces the same outcome. Nothing else in the view can mutate state.
- **Finite state machine rigour, without the ceremony.** All transitions live in one exhaustive `switch` over your `Event` enum. The compiler tells you when you've missed a case. No hidden paths, no forgotten edge cases.
- **Async work is explicit and named.** Nothing runs unless `update` returned an `Effect`. Tasks are tracked by name, automatically cancelled when the view disappears, and replaced if re-issued.
- **Test the entire view logic without a simulator.** Because `update` is a transition function with no async or network calls inside it, you can drive state, events, and async effects from a plain XCTest — no SwiftUI, no `@MainActor`, no mocking framework.


## How it maps to patterns you know

If you've used **VIPER**, think of `update` as the Presenter and Interactor collapsed into a single transition function. Events are inputs from the View; effects are the work the Interactor would kick off. The key difference: nothing executes inside `update` — it only *describes* what should happen. The library executes it.

If you use **MVVM with `@Observable`**, `ViewState` replaces your ViewModel's published properties, and `Event` replaces your ViewModel's public methods. The mental shift is that instead of calling `viewModel.loadMovies()` imperatively, you send an event and `update` decides what effect to run.

## Installation

Expand All @@ -32,7 +51,7 @@ Then add `EffectView` to your target dependencies.

1. **Define `State` and `Event`.** `State` is a plain value type holding everything the view needs to render. `Event` is an enum of all user actions and system notifications that can change state.

2. **Define a pure `update` function.** A `static` function that takes the current state and an event, mutates state in place, and optionally returns an `Effect` to run or cancel. No async, no throwing — just a switch.
2. **Define the transition function `update`.** A `static` function that takes the current state and an event, mutates state in place, and optionally returns an `Effect` to run or cancel. No async, no throwing — just a switch.

3. **Render and send.** The `EffectView` content closure receives the current state and a `send` function. Render state, and call `send` for user actions.

Expand Down Expand Up @@ -82,6 +101,24 @@ struct CounterView: View {
}
```

## What would take 20 lines in a ViewModel takes 5 here

Live search with automatic cancel-on-type — a task named `"search"` is automatically cancelled and restarted every time the query changes:

```swift
// update:
case .queryChanged(let q):
state.query = q
return .task(name: "search") { input, env in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
let results = await env.search(q)
input(.resultsLoaded(results))
}
```

No manual `Task` handles. No `debounce` publisher chain. No flag to reset.

## Behavior notes

- `update` is captured once when the view appears.
Expand Down
Loading