1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 02:18:40 +03:00

git flow, infra descriptions, cast flow changes (mobile side)

This commit is contained in:
Anton Afanasyeu
2026-05-23 12:18:43 +02:00
parent 41aa6659f5
commit a5ee5d7140
28 changed files with 1426 additions and 25 deletions

View File

@@ -0,0 +1,46 @@
---
description: Green-master git flow — branch from next, releases and hotfixes per GIT_FLOW.md
alwaysApply: true
---
# Git flow (green master)
Follow [docs/GIT_FLOW.md](docs/GIT_FLOW.md) and [docs/GIT_FLOW.pdf](docs/GIT_FLOW.pdf). Summary:
## Branches
| Branch | Use |
|--------|-----|
| **master** | Always green; **tags** (`vX.Y.Z`); merge only |
| **next** | Integration; all feature work merges here first |
| **feature/***, **fix/*** | Fork **next**; delete after merge |
| **hotfix/*** | Fork **master** for **released** bugs; merge → master → tag → merge master into **next** |
## Agent rules
1. **Do not** commit, push, or merge to **master** or **next** unless the user explicitly asks (release, hotfix, or sync step).
2. **Default work:** `git checkout next && git pull`, then `git checkout -b feature/...` or `fix/...`.
3. **Before merge to next:** run relevant tests (`./gradlew :app:testDebugUnitTest` for app changes; backend tests for PHP).
4. **Release:** only after user confirms — merge `next` → `master`, CI green, **tag on master**, then **merge master → next**.
5. **Hotfix (production):** branch from **master**, not next; after tag, **merge master into next**.
6. **Hotfix (next-only bug):** `fix/*` from **next** → merge to **next** only.
7. **Never** force-push `master` or `next`.
8. When working on a named branch, call **SetActiveBranch** with that branch name.
## Quick commands
```bash
# Start feature
git fetch && git checkout next && git pull && git checkout -b feature/my-change
# After hotfix on master
git checkout next && git merge master && git push origin next
```
If `next` is missing locally: create from `master` and `git push -u origin next` (maintainer once).
## Dual remote (private primary)
- **`origin`** (`git://f0xx.org/...`) = canonical; fetch/pull/push day-to-day work here.
- **`github`** = publish mirror; push `master` + tags (and optionally `next`) after release — do not treat GitHub as sole remote unless user says so.
- Topic branches: `git push -u origin feature/...` only unless user asks to publish to GitHub.

View File

@@ -86,3 +86,10 @@ Activities bind via AIDL (`ICastSenderService` / `ICastReceiverService`) for sta
- UDP mode may glitch under load
- Audio captures **playback** routed through the system (not microphone); some apps may be silent by policy
- Single sender → single receiver
## Development (git flow)
We use a **green `master`** plus integration branch **`next`**: features merge to `next` first; releases merge `next``master` and are tagged there; production hotfixes branch from `master` and sync back into `next`.
- Narrative: [docs/GIT_FLOW.md](docs/GIT_FLOW.md)
- PDF (diagrams): [docs/GIT_FLOW.pdf](docs/GIT_FLOW.pdf) — regenerate: `python3 docs/_pdf_build/build_git_flow_pdf.py`

View File

@@ -20,6 +20,7 @@ import android.text.TextUtils;
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
import com.foxx.androidcast.sender.SenderResolutionUiMode;
/**
* Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options.
@@ -52,6 +53,7 @@ public final class AppPreferences {
private static final String KEY_DEV_WIRED_DISPLAY_HINTS = "dev_wired_display_hints";
private static final String KEY_DEV_USB_TETHER_TRANSPORT = "dev_usb_tether_transport";
private static final String KEY_DEV_SENDER_RESOLUTION_UI_MODE = "dev_sender_resolution_ui_mode";
private static final String KEY_DEV_EXTERNAL_CAPTURE_POLICY = "dev_external_capture_policy";
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
private static final String KEY_DEV_COMMERCIAL_IAP = "dev_commercial_iap";
@@ -330,6 +332,22 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_USB_TETHER_TRANSPORT, enabled).apply();
}
/** Default: {@link SenderResolutionUiMode#SEPARATED}. */
public static SenderResolutionUiMode getDevSenderResolutionUiMode(Context context) {
String name = prefs(context).getString(
KEY_DEV_SENDER_RESOLUTION_UI_MODE, SenderResolutionUiMode.SEPARATED.name());
try {
return SenderResolutionUiMode.valueOf(name);
} catch (IllegalArgumentException e) {
return SenderResolutionUiMode.SEPARATED;
}
}
public static void setDevSenderResolutionUiMode(Context context, SenderResolutionUiMode mode) {
SenderResolutionUiMode value = mode != null ? mode : SenderResolutionUiMode.SEPARATED;
prefs(context).edit().putString(KEY_DEV_SENDER_RESOLUTION_UI_MODE, value.name()).apply();
}
public static ExternalDisplayCapturePolicy getExternalDisplayCapturePolicy(Context context) {
return ExternalDisplayCapturePolicy.fromName(
prefs(context).getString(KEY_DEV_EXTERNAL_CAPTURE_POLICY, null),

View File

@@ -102,7 +102,9 @@ public class CastSettings implements Serializable {
HD_720P(1.0f, 720, 0),
FIXED_1080P(0f, 1080, 1920),
FIXED_720P(0f, 720, 1280),
ADAPTIVE(1.0f, 0, 0);
ADAPTIVE(1.0f, 0, 0),
/** Long-edge cap 480p (aspect preserved). */
SD_480P(1.0f, 480, 0);
public final float scale;
public final int maxHeight;

View File

@@ -106,7 +106,7 @@ public final class CastSettingsBinder {
bindVideoCodec(activity, settings, R.id.spinner_sender_video_codec);
bindBitrate(activity, settings);
bindNetworkAdoption(activity, settings, R.id.spinner_sender_network);
bindSenderResolution(activity, settings);
com.foxx.androidcast.sender.SenderResolutionUi.bindSenderResolution(activity, settings);
bindCaptureMode(activity, settings);
bindStreamProtection(activity, settings);
bindAudioSpinner(activity, settings, R.id.spinner_sender_audio);
@@ -323,27 +323,8 @@ public final class CastSettingsBinder {
readEnumSpinner(activity.findViewById(spinnerId), settings::setNetworkAdoption);
}
private static void bindSenderResolution(AppCompatActivity activity, CastSettings settings) {
Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution);
CastSettings.Resolution[] modes = {
CastSettings.Resolution.ADAPTIVE,
CastSettings.Resolution.FIXED_1080P,
CastSettings.Resolution.FIXED_720P,
CastSettings.Resolution.FULL,
CastSettings.Resolution.THREE_QUARTERS
};
String[] labels = {
activity.getString(R.string.resolution_adaptive),
activity.getString(R.string.resolution_fixed_1080p),
activity.getString(R.string.resolution_fixed_720p),
activity.getString(R.string.resolution_full),
activity.getString(R.string.resolution_75)
};
bindEnumSpinner(spinner, modes, labels, settings.getResolution(), settings::setResolution);
}
private static void readSenderResolution(AppCompatActivity activity, CastSettings settings) {
readEnumSpinner(activity.findViewById(R.id.spinner_sender_resolution), settings::setResolution);
com.foxx.androidcast.sender.SenderResolutionUi.readSenderResolution(activity, settings);
}
public static void bindReceiverPip(AppCompatActivity activity) {
@@ -551,7 +532,7 @@ public final class CastSettingsBinder {
});
}
private static <T extends Enum<T>> void bindEnumSpinner(Spinner spinner, T[] values, String[] labels,
public static <T extends Enum<T>> void bindEnumSpinner(Spinner spinner, T[] values, String[] labels,
T current, java.util.function.Consumer<T> onSelect) {
spinner.setTag(R.id.tag_spinner_enum, values);
SettingsStringArrayAdapter adapter = new SettingsStringArrayAdapter(spinner.getContext(), labels);
@@ -581,7 +562,7 @@ public final class CastSettingsBinder {
});
}
private static <T extends Enum<T>> void readEnumSpinner(Spinner spinner,
public static <T extends Enum<T>> void readEnumSpinner(Spinner spinner,
java.util.function.Consumer<T> consumer) {
Object tag = spinner.getTag(R.id.tag_spinner_enum);
if (tag instanceof Enum[]) {

View File

@@ -54,6 +54,9 @@ public final class CastTuningEngine {
case HD_720P:
capLong = 720;
break;
case SD_480P:
capLong = 480;
break;
case FIXED_1080P:
boxW = 1920;
boxH = 1080;

View File

@@ -34,6 +34,7 @@ import com.foxx.androidcast.ota.OtaUpdateCoordinator;
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
import com.foxx.androidcast.sender.SenderResolutionUiMode;
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
import com.google.android.material.textfield.TextInputEditText;
@@ -88,6 +89,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
});
bindAvPresets();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
bindCommercialSection();
}
@@ -111,10 +113,37 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
findViewById(R.id.edit_ota_manifest_url),
findViewById(R.id.check_ota_on_launch));
bindAvPresets();
bindSenderResolutionUiModeSection();
bindWiredDisplaySection();
bindCommercialSection();
}
private void bindSenderResolutionUiModeSection() {
Spinner spinner = findViewById(R.id.spinner_dev_sender_resolution_ui_mode);
if (spinner == null) {
return;
}
final SenderResolutionUiMode[] modes = SenderResolutionUiMode.values();
String[] labels = {
getString(R.string.dev_sender_resolution_ui_separated),
getString(R.string.dev_sender_resolution_ui_legacy),
getString(R.string.dev_sender_resolution_ui_combined),
};
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, labels);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setAdapter(adapter);
spinner.setOnItemSelectedListener(null);
SenderResolutionUiMode current = AppPreferences.getDevSenderResolutionUiMode(this);
spinner.setSelection(current.ordinal(), false);
spinner.setOnItemSelectedListener(simpleSelection(() -> {
int pos = spinner.getSelectedItemPosition();
if (pos >= 0 && pos < modes.length) {
AppPreferences.setDevSenderResolutionUiMode(this, modes[pos]);
}
}));
}
private void bindWiredDisplaySection() {
TextView status = findViewById(R.id.text_wired_display_status);
if (status != null) {

View File

@@ -93,6 +93,8 @@ public final class CastDiagnosticsLabels {
return "50% display scale";
case HD_720P:
return "720p long-edge cap";
case SD_480P:
return "480p long-edge cap";
case FIXED_1080P:
return "Fixed 1080p box";
case FIXED_720P:

View File

@@ -0,0 +1,107 @@
package com.foxx.androidcast.sender;
import android.content.Context;
import androidx.appcompat.app.AppCompatActivity;
import android.widget.Spinner;
import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastSettingsBinder;
import com.foxx.androidcast.R;
/** Binds {@link R.id#spinner_sender_resolution} per {@link SenderResolutionUiMode}. */
public final class SenderResolutionUi {
private SenderResolutionUi() {}
public static SenderResolutionUiMode mode(Context context) {
return AppPreferences.getDevSenderResolutionUiMode(context);
}
public static void bindSenderResolution(AppCompatActivity activity, CastSettings settings) {
Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution);
if (spinner == null) {
return;
}
switch (mode(activity)) {
case LEGACY:
bindLegacy(activity, settings, spinner);
break;
case COMBINED:
bindCombined(activity, settings, spinner);
break;
case SEPARATED:
default:
bindSeparated(activity, settings, spinner);
break;
}
}
public static void readSenderResolution(AppCompatActivity activity, CastSettings settings) {
Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution);
if (spinner != null) {
CastSettingsBinder.readEnumSpinner(spinner, settings::setResolution);
}
}
/** Original / 720p / 480p / Auto. */
private static void bindSeparated(AppCompatActivity activity, CastSettings settings, Spinner spinner) {
CastSettings.Resolution[] modes = {
CastSettings.Resolution.FULL,
CastSettings.Resolution.HD_720P,
CastSettings.Resolution.SD_480P,
CastSettings.Resolution.ADAPTIVE,
};
String[] labels = {
activity.getString(R.string.resolution_preset_original),
activity.getString(R.string.resolution_preset_720p),
activity.getString(R.string.resolution_preset_480p),
activity.getString(R.string.resolution_preset_auto),
};
CastSettingsBinder.bindEnumSpinner(spinner, modes, labels, settings.getResolution(),
settings::setResolution);
}
/** Fixed 480p + fixed 720p + native original + bandwidth adaptive. */
private static void bindCombined(AppCompatActivity activity, CastSettings settings, Spinner spinner) {
CastSettings.Resolution[] modes = {
CastSettings.Resolution.SD_480P,
CastSettings.Resolution.HD_720P,
CastSettings.Resolution.FULL,
CastSettings.Resolution.ADAPTIVE,
};
String[] labels = {
activity.getString(R.string.resolution_preset_480p),
activity.getString(R.string.resolution_preset_720p),
activity.getString(R.string.resolution_combined_original),
activity.getString(R.string.resolution_combined_adaptive),
};
CastSettingsBinder.bindEnumSpinner(spinner, modes, labels, settings.getResolution(),
settings::setResolution);
}
/** Pre-refactor sender resolution list (subset previously in CastSettingsBinder). */
private static void bindLegacy(AppCompatActivity activity, CastSettings settings, Spinner spinner) {
CastSettings.Resolution[] modes = {
CastSettings.Resolution.ADAPTIVE,
CastSettings.Resolution.FIXED_1080P,
CastSettings.Resolution.FIXED_720P,
CastSettings.Resolution.FULL,
CastSettings.Resolution.THREE_QUARTERS,
CastSettings.Resolution.HALF,
CastSettings.Resolution.HD_720P,
};
String[] labels = {
activity.getString(R.string.resolution_adaptive),
activity.getString(R.string.resolution_fixed_1080p),
activity.getString(R.string.resolution_fixed_720p),
activity.getString(R.string.resolution_full),
activity.getString(R.string.resolution_75),
activity.getString(R.string.resolution_half),
activity.getString(R.string.resolution_720p),
};
CastSettingsBinder.bindEnumSpinner(spinner, modes, labels, settings.getResolution(),
settings::setResolution);
}
}

View File

@@ -0,0 +1,15 @@
package com.foxx.androidcast.sender;
/**
* Developer-only: how the sender resolution spinner is presented.
* <ul>
* <li>{@link #SEPARATED} — Original / 720p / 480p / Auto (default)</li>
* <li>{@link #LEGACY} — full pre-refactor preset list</li>
* <li>{@link #COMBINED} — fixed 480p + fixed 720p + full original + BW adaptive</li>
* </ul>
*/
public enum SenderResolutionUiMode {
SEPARATED,
LEGACY,
COMBINED
}

View File

@@ -45,6 +45,34 @@
android:layout_marginTop="12dp"
android:text="@string/dev_receiver_adaptive_display" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/dev_sender_resolution_ui_section"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_sender_resolution_ui_mode"
android:textSize="14sp" />
<Spinner
android:id="@+id/spinner_dev_sender_resolution_ui_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_sender_resolution_ui_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -46,6 +46,12 @@
<string name="resolution_fixed_1080p">1080p (1920×1080)</string>
<string name="resolution_fixed_720p">720p (1280×720)</string>
<string name="resolution_adaptive">Адаптивный (предсказание, в разработке)</string>
<string name="resolution_preset_original">Оригинал</string>
<string name="resolution_preset_720p">720p</string>
<string name="resolution_preset_480p">480p</string>
<string name="resolution_preset_auto">Авто</string>
<string name="resolution_combined_original">Оригинал (нативное)</string>
<string name="resolution_combined_adaptive">Адаптивный (оценка канала)</string>
<string name="label_video_codec">Видео кодек</string>
<string name="label_adjustment_preset">Тонкие быстрые настройки</string>
<string name="label_bitrate_mode">Bitrate</string>
@@ -188,6 +194,12 @@
<string name="licenses_title">Лицензии</string>
<string name="licenses_load_error">Не могу найти факл лицензий</string>
<string name="developer_settings_title">Для разработчиков</string>
<string name="dev_sender_resolution_ui_section">Разрешение отправителя (тест)</string>
<string name="dev_sender_resolution_ui_mode">Режим списка разрешений</string>
<string name="dev_sender_resolution_ui_separated">Раздельный (Оригинал / 720p / 480p / Авто)</string>
<string name="dev_sender_resolution_ui_legacy">Старый (полный список)</string>
<string name="dev_sender_resolution_ui_combined">Комбинированный (480p + 720p + оригинал + адаптив)</string>
<string name="dev_sender_resolution_ui_hint">Меняет список разрешений в настройках трансляции. По умолчанию: Раздельный. Откройте настройки cast заново.</string>
<string name="dev_wired_display_section">USB-C / HDMI (план D)</string>
<string name="dev_commercial_section">Монетизация (выкл. по умолчанию)</string>
<string name="transport_usb_tether">USB-тетер (эксперимент)</string>

View File

@@ -46,6 +46,12 @@
<string name="resolution_fixed_1080p">Fixed 1080p (fit in 1920×1080)</string>
<string name="resolution_fixed_720p">Fixed 720p (fit in 1280×720)</string>
<string name="resolution_adaptive">Adaptive (BW estimator, preview)</string>
<string name="resolution_preset_original">Original</string>
<string name="resolution_preset_720p">720p</string>
<string name="resolution_preset_480p">480p</string>
<string name="resolution_preset_auto">Auto</string>
<string name="resolution_combined_original">Original (native)</string>
<string name="resolution_combined_adaptive">Adaptive (BW estimator)</string>
<string name="label_video_codec">Video codec</string>
<string name="label_adjustment_preset">Cast adjustment preset</string>
<string name="label_bitrate_mode">Bitrate</string>
@@ -199,6 +205,12 @@
<string name="licenses_title">Licenses</string>
<string name="licenses_load_error">Could not load license text.</string>
<string name="developer_settings_title">Developer settings</string>
<string name="dev_sender_resolution_ui_section">Sender resolution UI (test)</string>
<string name="dev_sender_resolution_ui_mode">Resolution preset mode</string>
<string name="dev_sender_resolution_ui_separated">Separated (Original / 720p / 480p / Auto)</string>
<string name="dev_sender_resolution_ui_legacy">Legacy (full preset list)</string>
<string name="dev_sender_resolution_ui_combined">Combined (480p + 720p + original + adaptive)</string>
<string name="dev_sender_resolution_ui_hint">Changes the sender resolution spinner in cast settings. Default: Separated. Re-open cast settings to refresh labels.</string>
<string name="dev_wired_display_section">USB-C / HDMI (roadmap D)</string>
<string name="dev_wired_display_hints">Show sender status when external display is plugged in</string>
<string name="dev_wired_display_hint">Wired HDMI uses the OS display stack, not the cast protocol. If no second screen appears in system Display settings, adapters cannot be fixed in-app. See docs/USB_HDMI_CAST.md.</string>

View File

@@ -11,6 +11,8 @@ package com.foxx.androidcast;
* - Cursor Agent (project assistant)
* Digest: SHA256 6839d31665eec94c6c24a1a54c86dfaaef483d19196fe6b6a754eb7156c09b26
**********************************************************************/
import android.util.DisplayMetrics;
import com.foxx.androidcast.sender.calibration.CalibrationSourceDimensions;
import org.junit.Test;
@@ -27,4 +29,18 @@ public class CastResolutionTest {
assertEquals(400, size.height);
assertTrue(size.width > size.height);
}
@Test
public void sd480pCapsLongEdge() {
CastSettings settings = new CastSettings();
settings.setResolution(CastSettings.Resolution.SD_480P);
DisplayMetrics metrics = new DisplayMetrics();
metrics.widthPixels = 2000;
metrics.heightPixels = 1200;
metrics.densityDpi = 320;
CastTuningEngine.CaptureLimits limits = CastTuningEngine.resolveCaptureLimits(settings, null);
assertEquals(480, limits.maxHeight);
CastResolution.Size size = CastResolution.compute(metrics, settings, null);
assertEquals(480, Math.max(size.width, size.height));
}
}

384
docs/GIT_FLOW.md Normal file
View File

@@ -0,0 +1,384 @@
# Android Cast — Git flow (green `master`)
**Version:** 1.0 · **Last updated:** 2026-05-21
**PDF:** regenerate with `python3 docs/_pdf_build/build_git_flow_pdf.py``docs/GIT_FLOW.pdf`
This document defines how we develop, integrate, release, and hotfix. **Cursor agents and humans follow the same rules.**
---
## 1. Goals
| Goal | How |
|------|-----|
| **Always shippable `master`** | Only merges that passed CI/tests; tagged releases point here |
| **Safe integration** | Day-to-day work lands on `next` first |
| **Simple mental model** | Two long-lived branches + short-lived topic branches |
| **Recoverable hotfixes** | Production fixes branch from what users actually run |
---
## 2. Branches
| Branch | Role | Direct commits? |
|--------|------|-----------------|
| **`master`** | Production line; **green** (build + tests pass); release tags (`v0.1.2`) | **No** — merge only |
| **`next`** | Integration / release candidate; absorbs features until a release is approved | **No** — merge only |
| **`feature/*`**, **`fix/*`**, **`topic/*`** | Short-lived work; fork **`next`** | Yes (normal dev) |
| **`hotfix/*`** | Urgent fix for **released** code | Yes; merge target **`master`** first |
**Naming:** `feature/cast-resolution-presets`, `fix/crash-upload-500`, `hotfix/v0.1.1-pin-crash`.
If `next` does not exist yet on the remote:
```bash
git checkout master
git pull
git checkout -b next
git push -u origin next
```
---
## 3. Branch diagram (steady state)
```mermaid
flowchart LR
subgraph long["Long-lived"]
M[master\nshippable + tagged]
N[next\nintegration]
end
F[feature/*\nfix/*]
H[hotfix/*]
F -->|PR / merge after CI| N
N -->|release merge after CI| M
H -->|merge after CI| M
M -->|sync after release or hotfix| N
```
---
## 4. Daily development
1. **Start from `next`:** `git fetch && git checkout next && git pull`
2. **Create topic branch:** `git checkout -b feature/my-change`
3. **Commit** in small logical chunks on the topic branch.
4. **Before merge:** run project checks (Android: `./gradlew :app:testDebugUnitTest` or CI equivalent; backend: relevant tests/lint).
5. **Merge to `next`:** prefer merge commit or squash per team habit; **do not** merge unfinished work.
6. **Delete** topic branch after merge (local + remote).
```mermaid
sequenceDiagram
participant Dev as Developer / agent
participant F as feature/*
participant N as next
participant CI as CI / local tests
Dev->>F: branch from next
Dev->>F: commits
Dev->>CI: test
CI-->>Dev: green
Dev->>N: merge feature → next
Dev->>F: delete branch
```
---
## 5. Release: `next` → `master`
**When:** `next` is feature-complete for the version, soak/QA agreed, CI green.
1. Ensure **`next`** is up to date and **all tests pass**.
2. **Merge `next` → `master`** (merge commit recommended for traceability).
3. On **`master`:** final smoke / CI.
4. **Tag** on `master`: `git tag -a v0.1.2 -m "Release v0.1.2"` then `git push origin master --tags`.
5. **Sync `next` with `master`** so both lines share the release commit and any last-minute release fixes:
```bash
git checkout next
git merge master # fast-forward or merge commit
git push origin next
```
6. Continue new work: branch **`feature/*` from `next`** again.
```mermaid
sequenceDiagram
participant N as next
participant M as master
participant T as tag v0.1.x
Note over N: CI green, QA ok
N->>M: merge next → master
M->>M: CI / smoke
M->>T: annotated tag
M->>N: merge master → next (sync)
```
**Versioning:** tags on `master` only (`vMAJOR.MINOR.PATCH`). Bump per [SemVer](https://semver.org/) conventions you adopt for the app.
---
## 6. Hotfixes — chosen strategy
### Default: **hotfix from `master` (option 1)** ✓
Use when the bug affects **what is already released** (code on `master` / latest tag).
1. `git checkout master && git pull`
2. `git checkout -b hotfix/v0.1.2-crash-on-stop`
3. Fix + tests
4. Merge **`hotfix/*` → `master`**, CI green
5. **Tag** patch on `master`: e.g. `v0.1.3`
6. **`git checkout next && git merge master`** — bring hotfix into integration (resolve conflicts if `next` diverged)
7. Push `master`, `next`, tags; delete `hotfix/*`
**Why not hotfix only on `next` (option 2)?**
`next` often contains unreleased features. Fixing there delays shipping the patch and risks dragging unrelated commits into production when you later merge `next` → `master`.
### Exception: fix only on `next` (option 2)
Use when the bug **does not exist on `master`** (introduced on `next` only).
1. `git checkout -b fix/...` from **`next`**
2. Merge to **`next`** only
3. No tag until the normal **release** merge `next` → `master`
```mermaid
flowchart TD
Q{BUG IN RELEASED\nmaster / tag?}
Q -->|Yes| H[hotfix/* from master]
H --> M[merge → master]
M --> T[tag patch on master]
T --> S[merge master → next]
Q -->|No, next only| F[fix/* from next]
F --> N[merge → next]
```
---
## 7. Protection rules (CI + policy)
| Rule | `master` | `next` | topic branches |
|------|----------|--------|----------------|
| Required CI (build + tests) before merge | ✓ | ✓ | ✓ (before PR/merge) |
| No direct pushes of WIP | ✓ | ✓ | — |
| Only maintainers merge to `master` | ✓ | optional | — |
| Tag only on `master` | ✓ | — | — |
See **[§7.1 How to protect branches](#71-how-to-protect-branches-not-local-hooks-alone)** below.
### 7.1 How to protect branches (not local hooks alone)
**Short answer:** use **server-side** rules on `f0xx.org` (or whatever hosts `origin`). **Local git hooks** on your laptop are optional helpers only — they do not stop you or someone else from pushing bad history from another machine.
| Layer | Where it runs | Stops bad pushes? | Good for |
|-------|----------------|-------------------|----------|
| **Hosting UI / branch protection** | Git server (GitHub, GitLab, Gitea, …) | **Yes** | Most teams; use this if available |
| **Server git hooks** (`pre-receive` / `update`) | Bare repo on server | **Yes** | Self-hosted `git://` / SSH without a web UI |
| **Local hooks** (`.git/hooks/pre-push`) | Your PC only | **No** (easy to skip) | Personal reminders, format/tests before push |
| **CI** (build on push/PR) | CI runner | Indirect | Prove tests passed; pair with “required check” on server |
#### What to enable on `master` and `next`
1. **No force-push** (no `git push --force` rewriting history).
2. **No direct push** (optional but strong): changes only via merge from `feature/*` or from PR/MR — you merge locally or on the web, but the server rejects `git push origin master` with a normal commit on top without review (exact option name varies).
3. **Require CI green** before merge (if you have Actions/GitLab CI/Gitea Actions).
4. **Restrict who can push** to you (and CI deploy key if needed).
`feature/*` branches stay **unprotected** — push freely.
#### If your host has a web UI (Gitea / GitLab / GitHub)
Look for **Branch protection** / **Protected branches**:
- Add rules for `master` and `next`.
- Enable: prevent force-push, require pull request (optional), require status checks.
No git internals required — the server rejects illegal pushes.
#### If you only have bare git on a server (`git://` / SSH)
Ask whoever administers `git://f0xx.org/android_cast` to install **server-side hooks** in the **bare repository** (not in your clone):
- `hooks/pre-receive` or `hooks/update` — script receives branch name + old/new SHA; **exit 1** to reject (e.g. block direct push to `refs/heads/master` unless user is deploy; block all force-push to `master`/`next`).
Example policy in words: “reject push if ref is `master` or `next` and user is not in allowlist” or “reject non-fast-forward updates to `master`/`next`”.
Local clone hooks live in `.git/hooks/` and are **not copied** when others clone — so they are **not** team protection.
#### Local hooks (optional, for you only)
Useful on your machine, not security:
```bash
# .git/hooks/pre-push (chmod +x) — example: warn before pushing to master
protected_regex='^(refs/heads/)?(master|next)$'
while read local_ref local_sha remote_ref remote_sha; do
if echo "$remote_ref" | grep -qE "$protected_regex"; then
echo "Blocked: push to master/next — use feature branch + merge (see docs/GIT_FLOW.md)"
exit 1
fi
done
```
Skip with `git push --no-verify` — another reason server rules matter.
#### CI without branch UI
Run tests on every push; you still **choose** not to merge until green. Branch protection + “required check” automates that on modern hosts.
---
## 8. Sync cheat sheet
| Event | Action |
|-------|--------|
| After **release** (`next` → `master` + tag) | `git checkout next && git merge master && git push` |
| After **hotfix** to `master` + tag | Same: **merge `master` → `next`** |
| Feature finished | merge **topic → `next`** only |
| Started wrong base | rebase topic onto latest `next` (coordinate if already shared) |
---
## 9. Agent / Cursor checklist
Agents working in this repo **must**:
1. **Never** commit or push directly to `master` or `next` unless the user explicitly asks for that merge/release step.
2. **Branch from `next`** for features and non-production fixes (`feature/*`, `fix/*`).
3. **Run tests** (or state clearly if not run) before recommending merge to `next`.
4. **Releases:** only describe merging `next` → `master` and tagging after user confirms QA.
5. **Hotfixes on released code:** branch from `master`, merge back to `master`, tag, then **merge `master` into `next`**.
6. **Set active branch** metadata when using a named topic branch (project convention).
7. **Do not** force-push `master` / `next`.
---
## 10. Comparison to classic Git Flow
| Git Flow | This repo |
|----------|-----------|
| `main` | `master` (green, tagged) |
| `develop` | `next` |
| `feature/*` | same, base = `next` |
| `release/*` | optional short branch; default = test on `next` then merge to `master` |
| `hotfix/*` | from **`master`**, merge to **`master`**, sync to **`next`** |
We intentionally avoid a permanently divergent `develop` without a green production branch: **`master` is always releasable.**
---
## 11. First-time setup (maintainer)
```bash
# Ensure next exists and tracks origin
git checkout master && git pull
git checkout -b next 2>/dev/null || git checkout next
git push -u origin next
# Optional: protect branches on hosting (GitHub example)
# gh api repos/{owner}/{repo}/branches/master/protection ...
```
---
## 12. Private primary + GitHub publish (dual remote)
**Canonical repo:** your server — today `origin` → `git://f0xx.org/android_cast`.
**Daily work:** always `git push` / `git pull` against **`origin`** first.
**GitHub:** secondary remote for visibility, issues, and (optional) GitHub Actions — not the source of truth unless you decide otherwise.
### 12.1 Add GitHub without replacing origin
```bash
# Create empty repo on GitHub (no README if you already have history)
git remote add github git@github.com:YOUR_USER/android-cast.git
# origin stays: git://f0xx.org/android_cast
git remote -v
```
Keep **`origin`** = private server. Name the public remote **`github`** (or `publish`) so commands stay obvious.
### 12.2 What to push where
| Ref | Private `origin` | GitHub `github` |
|-----|------------------|-----------------|
| `feature/*`, WIP | ✓ always | optional (omit for cleaner public) |
| `next` | ✓ always | your choice: public integration or keep private |
| `master` + tags | ✓ always | ✓ publish releases you want visible |
| Secrets, `local.properties`, logs | never in either | never |
**Typical OSS-style:** push everything to **`origin`**; push only **`master`** and **tags** to **`github`** until you are ready to open `next`.
```bash
# After merge to master on private server
git push origin master --tags
git push github master --tags
```
Push `next` to GitHub only when you want public integration:
```bash
git push origin next
git push github next
```
### 12.3 Daily habit
```bash
git fetch origin
git checkout next && git pull origin next
# ... work on feature/foo ...
git push -u origin feature/foo
# merge to next on origin (PR or local merge + push)
git push origin next
# When ready to publish a release line to GitHub
git push github master --tags
```
Set upstream on topic branches to **origin**, not github:
```bash
git push -u origin feature/my-change
```
### 12.4 Where branch protection lives
| Server | Protect |
|--------|---------|
| **f0xx.org (`origin`)** | `master`, `next` — server hooks or admin UI (primary enforcement) |
| **GitHub (`github`)** | `master` (and `next` if public) — Settings → Branch protection |
GitHub rules only affect pushes **to GitHub**. They do not protect your private server unless you also configure **`origin`**.
### 12.5 One-way mirror (optional automation)
On the private server (cron or post-receive hook), after a successful push to `origin`:
```bash
git push --prune github +refs/heads/master:refs/heads/master +refs/tags/*:refs/tags/*
```
Or use GitHubs “mirror repository” importers only for initial import; ongoing sync is usually `git push github …` from CI or hook.
Avoid `git push github --mirror` unless GitHub is a full duplicate of **private** history — it copies all branches and deletes remote branches you deleted locally.
### 12.6 Suggested order of operations (your situation)
1. On **f0xx.org:** create **`next`** from `master`, push `origin next`.
2. On **f0xx.org:** enable server protection for `master` / `next` (admin).
3. Create **GitHub** repo; `git remote add github …`.
4. First publish: `git push github master` and tags you want public.
5. Enable **GitHub branch protection** on `master` (block force-push).
6. Continue feature flow on **`origin`** only; publish to GitHub when you merge/tag on `master`.
---
## Related
- [ROADMAP.md](ROADMAP.md) — product tracks
- [.cursor/rules/git-flow.mdc](../.cursor/rules/git-flow.mdc) — agent enforcement
- `docs/_pdf_build/build_git_flow_pdf.py` — PDF generator

200
docs/GIT_FLOW.pdf Normal file

File diff suppressed because one or more lines are too long

76
docs/OPEN_API.md Normal file
View File

@@ -0,0 +1,76 @@
# Crash console — open ingest API (design)
Triage **real device crashes** vs **synthetic / bulk test traffic** when exposing upload or query APIs beyond the Android app. Builds on RBAC ([`examples/crash_reporter/backend/README.md`](../examples/crash_reporter/backend/README.md)).
## Problems
- Load tests and CI may POST many similar JSON blobs → drown signal in the reports list.
- Third-party integrations need stable auth and filters without console login cookies.
- Similarity grouping already uses payload fingerprints; need **provenance** to exclude known harness traffic.
## Payload extension (ingest)
Add optional top-level fields on upload (backward compatible):
| Field | Type | Meaning |
|-------|------|---------|
| `ingest.source` | string | `device` (default), `open_api`, `ci`, `manual` |
| `ingest.client_id` | string | Stable id per API key or CI job |
| `ingest.run_id` | string | Correlates a burst of reports from one test run |
Android app omits these (defaults to `device`). Harness sets `open_api` + `run_id`.
Store in DB:
- Column `reports.ingest_source` (indexed) **or** JSON path in `payload_json` with generated column later.
- Prefer column for list filters and MariaDB permissions story.
## Upload API (`POST …/api/upload.php`)
Phases:
1. **Phase A (now):** Accept extra JSON fields; persist inside `payload_json`; console search includes `ingest.source` in blob.
2. **Phase B:** API key header `X-Crash-Api-Key` → maps to `company_id` (RBAC); rate limit per key.
3. **Phase C:** Reject or quarantine when `ingest.source=open_api` and fingerprint matches org blocklist.
## Console / query API (read)
Thin JSON endpoints (session or API key):
| Endpoint | Purpose |
|----------|---------|
| `GET /api/reports?ingest_source=device` | Default triage view |
| `GET /api/reports?ingest_source_ne=open_api` | Hide harness |
| `GET /api/reports?group=fingerprint&min_count=N` | Bulk duplicate detector |
| `GET /api/reports/{id}` | Detail (existing shape) |
UI: filter chips on reports toolbar — **Devices only** / **Include API traffic** / tag `synthetic`.
## Classifying synthetic traffic (heuristics)
Mark report or tag `synthetic` when any:
- `ingest.source` in (`open_api`, `ci`) **and** user has not promoted it.
- Same `ingest.run_id` + fingerprint count &gt; threshold in 10 minutes.
- Identical `report_id` prefix pattern from known test fixture.
Manual: console **Clear synthetic tag** / **Promote to real** on detail page.
## Security
- API keys scoped to `company_id`; no cross-tenant list.
- Separate write-only keys for CI upload vs read keys for dashboards.
- Log upload failures to existing crash event log; no stack traces in response body.
## Android
No change required for v1. Optional later: dev setting to set `ingest.source=manual` for dogfood builds.
## Implementation order
1. Document fields; parse and store in payload (Phase A).
2. List filter `ingest_source` in `ReportRepository` + UI chip.
3. API keys table + `X-Crash-Api-Key` on upload.
4. Grouped bulk endpoint for duplicate review.
See also [CRASH_REPORTER.md](CRASH_REPORTER.md) and `examples/crash_reporter/ROADMAP.md`.

77
docs/PRO_AAR.md Normal file
View File

@@ -0,0 +1,77 @@
# Pro / OSS split (cast-core + cast-pro AAR)
Design notes for a commercial build without forking the cast protocol. **Not implemented yet** — complements [COMMERCIAL.md](COMMERCIAL.md) (dev-gated Play Billing placeholders).
## Goals
- **OSS (`cast-core`)** — protocol, UDP/TCP transport, receiver, sender engine, crash reporter client, settings model. Publishable repo for trust and forks.
- **Closed (`cast-pro` AAR)** — entitlements, license check, optional premium codecs/UI, Play Billing glue. Distributed via private Maven or Play App Bundle module (not on public Maven).
- **Single APK** on Play: `app` depends on `cast-core` + `cast-pro` at compile time; F-Droid / OSS build uses `cast-core` only with stub entitlements.
## Entitlements API (in core)
Keep a thin interface in core so free code never imports Play Billing:
```java
public interface CastEntitlements {
boolean isPro(Context ctx);
int maxReceivers();
boolean allowsResolution(CastSettings.Resolution r);
boolean allowsStreamProtection();
}
```
- **Default (OSS):** `FreeCastEntitlements` — e.g. max 1 receiver, cap outgoing long edge at 480 unless dev override, FEC/NACK off or dev-only.
- **Pro:** `ProCastEntitlements` in AAR — reads signed license / Play purchase cache, returns higher limits.
Call sites (minimal first pass):
| Area | Gate |
|------|------|
| `CastTuningEngine` / sender encode | `allowsResolution()` |
| Multi-receiver connect | `maxReceivers()` |
| Stream protection UI | `allowsStreamProtection()` |
| Developer resolution UI modes | Always allowed when `BuildConfig.DEBUG` or `AppPreferences` dev flags |
`CommercialFeatures` stays **developer toggles** for ads/IAP/Play review; **pro entitlement** is runtime product state from Billing + AAR.
## Module layout (Gradle)
```
cast-core/ # android library, published OSS
cast-pro/ # android library, proprietary, implementation project OR maven coords
app/ # application
```
`app/build.gradle`:
- `implementation project(':cast-core')`
- `implementation 'com.foxx.androidcast:cast-pro:…'` (release) or `implementation project(':cast-pro')` (internal)
- Optional `ossRelease` flavor: `implementation project(':cast-core')` only + `FreeCastEntitlements`
**ServiceLoader or manifest meta-data** to bind `CastEntitlements` implementation avoids `compileOnly` reflection hacks.
## Play Billing flow
1. User buys SKU (see `InAppPurchaseCoordinator.SKU_PREMIUM`).
2. Pro AAR verifies purchase token, caches entitlement locally (encrypted prefs or Play Library `BillingClient` + acknowledge).
3. `CastEntitlements` refreshes; UI shows unlocked resolutions / receivers.
4. Offline grace period (e.g. 7 days) documented in product terms.
## Build & release
- Version **cast-core** and **cast-pro** independently; app pins both in `gradle/libs.versions.toml`.
- CI: OSS job builds `cast-core` + F-Droid APK; private job signs `cast-pro` and release APK.
- Do not embed license secrets in OSS; pro AAR may contain obfuscated verification only.
## Migration steps (suggested order)
1. Extract `CastEntitlements` + `FreeCastEntitlements` in app (no module split yet).
2. Wire gates at encode + multi-receiver (behind dev “simulate free tier” toggle).
3. Create `:cast-core` library — move `network/`, `cast/`, shared settings (large refactor).
4. Add `:cast-pro` with `ProCastEntitlements` + Billing bridge.
5. Play Console product + privacy policy ([COMMERCIAL.md](COMMERCIAL.md)).
## Relation to sender resolution test
Developer **Sender resolution UI mode** ([`SenderResolutionUi`](../app/src/main/java/com/foxx/androidcast/sender/SenderResolutionUi.java)) is for UX experiments only. Production free tier should still enforce caps in `CastTuningEngine` via entitlements, not by hiding spinner entries alone.

View File

@@ -66,7 +66,17 @@ All **off by default**. Enable only in **Developer settings** for internal testi
| AdMob / unit IDs / banner layouts in main UI | TODO |
| Play Console products + privacy policy | TODO |
See [COMMERCIAL.md](COMMERCIAL.md).
See [COMMERCIAL.md](COMMERCIAL.md). Pro/OSS module split: [PRO_AAR.md](PRO_AAR.md) (design).
---
## Crash console open API
| Item | Status |
|------|--------|
| `ingest.source` / run metadata in payload | Design — [OPEN_API.md](OPEN_API.md) |
| List filters + synthetic tagging | TODO |
| API key upload auth (per company) | TODO |
---
@@ -126,6 +136,8 @@ See [ndk/README.md](../ndk/README.md).
| Multi-receiver 1:N | Done (soak or gate for alpha) |
| Legacy `ReceiverActivity` in manifest | Orphan — remove or wire |
| Immersive A/V presets (dev) | Experimental |
| Sender resolution UI modes (dev: Separated / Legacy / Combined) | Done (test) |
| True dual-stream encode (480p + 720p simultaneous) | Deferred |
---
@@ -142,8 +154,11 @@ See [ndk/README.md](../ndk/README.md).
## Related docs
- [GIT_FLOW.md](GIT_FLOW.md) — green `master` / `next` development cycle ([PDF](GIT_FLOW.pdf))
- [README.md](../README.md) — quick start
- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E
- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play
- [PRO_AAR.md](PRO_AAR.md) — cast-core / cast-pro entitlements
- [OPEN_API.md](OPEN_API.md) — crash ingest triage API
- [OTA.md](OTA.md) — backend layout v0
- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""Build docs/GIT_FLOW.pdf from structured content and Mermaid diagrams.
Source of truth for edits: docs/GIT_FLOW.md (narrative) + this script (PDF layout).
Regenerate: python3 docs/_pdf_build/build_git_flow_pdf.py
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
ROOT = Path(__file__).resolve().parents[2]
BUILD = Path(__file__).resolve().parent
OUT_PDF = ROOT / "docs" / "GIT_FLOW.pdf"
DIAGRAMS = [
("git_flow_diagram_branches.mmd", "git_flow_diagram_branches.png", "Branch model (green master)"),
("git_flow_diagram_feature.mmd", "git_flow_diagram_feature.png", "Daily development"),
("git_flow_diagram_release.mmd", "git_flow_diagram_release.png", "Release: next → master"),
("git_flow_diagram_hotfix.mmd", "git_flow_diagram_hotfix.png", "Hotfix decision and flow"),
]
def render_mermaid() -> None:
for mmd, png, _ in DIAGRAMS:
src = BUILD / mmd
dst = BUILD / png
if not src.exists():
raise FileNotFoundError(src)
subprocess.run(
[
"npx",
"--yes",
"@mermaid-js/mermaid-cli@11.4.0",
"-i",
str(src),
"-o",
str(dst),
"-b",
"white",
"-w",
"1200",
"-H",
"800",
],
cwd=str(ROOT),
check=True,
timeout=180,
)
def p(text: str, style) -> Paragraph:
return Paragraph(text.replace("\n", "<br/>"), style)
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
data = [headers] + rows
t = Table(data, colWidths=col_widths)
t.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(t)
def add_diagram(story, mmd_png: str, caption: str, h2, iw: float = 16 * cm) -> None:
path = BUILD / mmd_png
if path.exists():
story.append(p(caption, h2))
story.append(Image(str(path), width=iw, height=iw * 0.55))
story.append(Spacer(1, 0.35 * cm))
def build_pdf() -> None:
styles = getSampleStyleSheet()
title = ParagraphStyle(
"DocTitle",
parent=styles["Title"],
fontSize=18,
spaceAfter=10,
textColor=colors.HexColor("#1565C0"),
)
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=12, spaceAfter=6)
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=11, spaceBefore=8, spaceAfter=4)
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=14, bulletIndent=6)
story: list = []
story.append(p("Android Cast — Git flow (green master)", title))
story.append(
p(
"Development cycles, branch roles, release and hotfix strategy. "
"Version 1.0 · 2026-05-21. Markdown source: docs/GIT_FLOW.md",
small,
)
)
story.append(Spacer(1, 0.3 * cm))
# 1 Goals
story.append(p("1. Goals", h1))
add_table(
story,
["Goal", "Mechanism"],
[
["Always shippable master", "Merge to master only when CI/tests pass"],
["Safe integration", "Features land on next first"],
["Clear production line", "Release tags only on master"],
["Fast production patches", "hotfix/* from master, then sync to next"],
],
[4.5 * cm, 12 * cm],
)
story.append(Spacer(1, 0.4 * cm))
# 2 Branches
story.append(p("2. Branches", h1))
add_table(
story,
["Branch", "Role", "Direct commits?"],
[
["master", "Production; green; annotated tags vX.Y.Z", "No — merge only"],
["next", "Integration / release candidate", "No — merge only"],
["feature/*, fix/*", "Short-lived work", "Yes; merge → next"],
["hotfix/*", "Urgent fix for released code", "Yes; merge → master first"],
],
[3 * cm, 8.5 * cm, 4 * cm],
)
story.append(Spacer(1, 0.3 * cm))
add_diagram(story, DIAGRAMS[0][1], DIAGRAMS[0][2], h2)
story.append(PageBreak())
# 3 Daily dev
story.append(p("3. Daily development", h1))
for line in [
"1. git fetch && git checkout next && git pull",
"2. git checkout -b feature/short-description",
"3. Commit on topic branch; run tests before merge",
"4. Merge topic → next (after CI green)",
"5. Delete topic branch",
]:
story.append(p(f"{line}", bullet))
story.append(Spacer(1, 0.2 * cm))
story.append(
p(
"<b>Agents:</b> never push directly to master or next unless the user explicitly "
"requests a release or hotfix merge.",
body,
)
)
add_diagram(story, DIAGRAMS[1][1], DIAGRAMS[1][2], h2, iw=15 * cm)
story.append(PageBreak())
# 4 Release
story.append(p("4. Release (next → master)", h1))
for line in [
"Confirm next is QA-ready and CI green",
"Merge next → master",
"Smoke / CI on master",
"Tag on master: git tag -a v0.1.2 -m \"Release v0.1.2\"",
"Sync: git checkout next && git merge master && git push",
"Continue feature branches from updated next",
]:
story.append(p(f"{line}", bullet))
add_diagram(story, DIAGRAMS[2][1], DIAGRAMS[2][2], h2, iw=15 * cm)
story.append(PageBreak())
# 5 Hotfix
story.append(p("5. Hotfixes — recommended strategy", h1))
story.append(
p(
"<b>Default (option 1):</b> branch hotfix/* from <b>master</b> when the bug affects "
"released code. After fix and tests: merge → master, tag patch (e.g. v0.1.3), then "
"<b>merge master → next</b> so integration does not miss the fix.",
body,
)
)
story.append(Spacer(1, 0.15 * cm))
story.append(
p(
"<b>Exception (option 2):</b> bug exists only on next — branch fix/* from next, "
"merge to next only; ships with the next normal release.",
body,
)
)
story.append(Spacer(1, 0.15 * cm))
story.append(
p(
"<b>Why not hotfix only on next?</b> next often contains unreleased features; "
"merging next to master for a patch would ship extra code or block the fix.",
body,
)
)
add_diagram(story, DIAGRAMS[3][1], DIAGRAMS[3][2], h2, iw=14 * cm)
story.append(p("5.1 Sync cheat sheet", h2))
add_table(
story,
["Event", "Action"],
[
["After release + tag", "merge master → next"],
["After hotfix + tag", "merge master → next"],
["Feature done", "merge topic → next only"],
["Wrong base branch", "rebase topic onto latest next"],
],
[4.5 * cm, 12 * cm],
)
story.append(PageBreak())
# 6 Protection + agents
story.append(p("6. Branch protection and CI", h1))
add_table(
story,
["Rule", "master", "next"],
[
["CI required before merge", "yes", "yes"],
["No WIP direct push", "yes", "yes"],
["Annotated release tags", "yes", "no"],
["Force-push", "forbidden", "forbidden"],
],
[5.5 * cm, 5 * cm, 5 * cm],
)
story.append(Spacer(1, 0.35 * cm))
story.append(p("7. Cursor / agent checklist", h1))
for line in [
"Branch from next for features and non-production fixes",
"Run tests before recommending merge to next",
"Releases and tags only with explicit user approval",
"Hotfix released code from master; sync master into next after tag",
"Never force-push master or next",
]:
story.append(p(f"{line}", bullet))
story.append(Spacer(1, 0.4 * cm))
story.append(
p(
"Full narrative and Mermaid sources: docs/GIT_FLOW.md, "
"docs/_pdf_build/git_flow_diagram_*.mmd · "
"Regenerate PDF: python3 docs/_pdf_build/build_git_flow_pdf.py",
small,
)
)
doc = SimpleDocTemplate(
str(OUT_PDF),
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
)
doc.build(story)
print(f"Wrote {OUT_PDF}")
def main() -> int:
try:
render_mermaid()
except subprocess.CalledProcessError as e:
print("Mermaid render failed:", e, file=sys.stderr)
return 1
except FileNotFoundError as e:
print(e, file=sys.stderr)
return 1
build_pdf()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,21 @@
flowchart TB
subgraph legend[" "]
direction LR
L1[Long-lived]
L2[Short-lived]
end
M["master\n(always green, tagged releases)"]
N["next\n(integration / release candidate)"]
F["feature/* fix/*\n(fork next)"]
H["hotfix/*\n(fork master)"]
F -->|"merge + CI green"| N
N -->|"release: merge + CI green"| M
H -->|"urgent patch + CI green"| M
M -->|"sync after release or hotfix"| N
style M fill:#c8e6c9,stroke:#2e7d32
style N fill:#bbdefb,stroke:#1565c0
style F fill:#fff9c4,stroke:#f9a825
style H fill:#ffccbc,stroke:#e64a19

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,15 @@
sequenceDiagram
autonumber
participant Dev as Developer or agent
participant Topic as feature/* or fix/*
participant Next as next
participant CI as Tests / CI
Dev->>Next: fetch, checkout, pull
Dev->>Topic: create branch from next
Dev->>Topic: commits
Dev->>CI: run tests
CI-->>Dev: pass
Dev->>Next: merge topic branch
Note over Next: next stays integration line
Dev->>Topic: delete branch

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,21 @@
flowchart TD
Start([Defect found])
Q{Affects released code\non master / latest tag?}
Start --> Q
Q -->|Yes| A[Branch hotfix/* from master]
A --> B[Fix + tests]
B --> C[Merge to master]
C --> D[Tag patch on master e.g. v0.1.3]
D --> E[Merge master into next]
E --> End([Done])
Q -->|No, only on next| F[Branch fix/* from next]
F --> G[Fix + tests]
G --> H[Merge to next only]
H --> End2([Ships with next release])
style C fill:#c8e6c9
style D fill:#c8e6c9
style E fill:#bbdefb
style H fill:#bbdefb

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,16 @@
sequenceDiagram
autonumber
participant Next as next
participant Master as master
participant CI as Tests / CI
participant Tag as tag vX.Y.Z
Note over Next: feature complete, QA ok
Next->>CI: full test suite
CI-->>Next: green
Next->>Master: merge next to master
Master->>CI: smoke on master
CI-->>Master: green
Master->>Tag: annotated tag on master
Master->>Next: merge master into next (sync)
Note over Next: continue features from synced next

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB