1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
//! Effective claim-policy resolution. Layers, lowest precedence first:
//!
//! 1. Repo-default config (`.balls/config.json`, committed to main and
//! shared across clones).
//! 2. Per-clone override (`.balls/local/config.json`, gitignored). All
//! fields are optional; only those set override the repo default.
//! 3. Per-invocation flag (`--sync` / `--no-sync` on `bl claim`).
//!
//! Out of scope: enforcement. A dev who flips `--no-sync` against a
//! repo whose maintainer set `require_remote_on_claim = true` is on
//! their honour. The policy guides default behaviour; the rest is
//! social.
use crate::config::Config;
use crate::error::Result;
use crate::participant_config::LocalPluginEntry;
use crate::store::Store;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
/// Per-clone override of the repo-default `Config`. Stored at
/// `.balls/local/config.json`. All fields optional — `None` (or an
/// empty map) means "inherit the repo-default".
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct LocalConfig {
#[serde(default)]
pub require_remote_on_claim: Option<bool>,
#[serde(default)]
pub require_remote_on_review: Option<bool>,
#[serde(default)]
pub require_remote_on_close: Option<bool>,
/// Per-clone override of `Config.state_remote`. `bl remaster`
/// writes it here by default so the project link is per-clone
/// (and `--detach` writes `origin` here to shadow a committed hub
/// and go standalone). Layered over the committed value by
/// `state_remote_opt`. `None` ⇒ inherit the committed config.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_remote: Option<String>,
/// SPEC §11 — per-plugin participant policy overrides. Only
/// plugins the clone actually wants to override appear here.
#[serde(default)]
pub plugins: BTreeMap<String, LocalPluginEntry>,
}
impl LocalConfig {
pub fn path(store: &Store) -> PathBuf {
store.local_dir().join("config.json")
}
/// Load if present. A missing file is not an error — that's the
/// common case. A malformed file is, so the caller knows to fix
/// it instead of silently inheriting the repo default.
pub fn load(store: &Store) -> Result<Option<Self>> {
let p = Self::path(store);
if !p.exists() {
return Ok(None);
}
let s = fs::read_to_string(&p)?;
let cfg: LocalConfig = serde_json::from_str(&s)?;
Ok(Some(cfg))
}
/// Persist this per-clone override to `.balls/local/config.json`,
/// creating the directory if needed. Used by `bl remaster` to
/// record the per-clone state-remote link.
pub fn save(&self, store: &Store) -> Result<()> {
let p = Self::path(store);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&p, serde_json::to_string_pretty(self)? + "\n")?;
Ok(())
}
}
/// Layered `state_remote`, per the bl-2148 precedence (per-clone
/// override beats committed default). `None` means neither side set
/// it — the caller applies its own default (`origin` for lifecycle
/// and init; the `bl sync --remote` value for the standalone sync
/// path, preserving byte-identical behavior). This is the single
/// place the local-over-committed precedence lives.
pub fn state_remote_opt(cfg: &Config, local: Option<&LocalConfig>) -> Option<String> {
local
.and_then(|l| l.state_remote.clone())
.or_else(|| cfg.state_remote.clone())
}
/// CLI-side override: which way (if any) the user pushed the toggle.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SyncOverride {
#[default]
Unset,
Sync,
NoSync,
}
/// Resolved claim-time policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClaimPolicy {
pub require_remote: bool,
/// True when the value comes from the repo-default config and the
/// local clone has not previously been told about it. Drives the
/// one-time "this repo requests synced claims" hint surfaced by
/// `bl prime`.
pub from_repo_default: bool,
}
/// Compute the effective claim policy for this invocation.
///
/// `local` is `LocalConfig::load(store)?` factored out so callers that
/// have already loaded it (e.g. `bl prime`'s UX path) don't read twice.
pub fn resolve(
repo_default: bool,
local: Option<&LocalConfig>,
cli: SyncOverride,
) -> ClaimPolicy {
resolve_inner(
repo_default,
local.and_then(|l| l.require_remote_on_claim),
cli,
)
}
/// Compute the effective review-time sync policy. Mirrors `resolve`
/// but reads the review-specific fields. The struct shape is shared
/// so callers can treat all three lifecycle policies uniformly.
pub fn resolve_review(
repo_default: bool,
local: Option<&LocalConfig>,
cli: SyncOverride,
) -> ClaimPolicy {
resolve_inner(
repo_default,
local.and_then(|l| l.require_remote_on_review),
cli,
)
}
/// Compute the effective close-time sync policy. See `resolve_review`.
pub fn resolve_close(
repo_default: bool,
local: Option<&LocalConfig>,
cli: SyncOverride,
) -> ClaimPolicy {
resolve_inner(
repo_default,
local.and_then(|l| l.require_remote_on_close),
cli,
)
}
fn resolve_inner(
repo_default: bool,
local_value: Option<bool>,
cli: SyncOverride,
) -> ClaimPolicy {
let after_local = local_value.unwrap_or(repo_default);
let from_repo_default = local_value.is_none() && repo_default;
let require_remote = match cli {
SyncOverride::Sync => true,
SyncOverride::NoSync => false,
SyncOverride::Unset => after_local,
};
ClaimPolicy { require_remote, from_repo_default }
}
/// Marker file path (under `.balls/local/`) recording that this clone
/// has already seen — and been notified about — the repo-default
/// claim-sync policy. The file's mere existence is the signal; its
/// contents are informative only.
fn seen_marker_path(store: &Store) -> PathBuf {
store.local_dir().join("seen-claim-sync-policy")
}
/// One-time hint, written to stderr the first time a clone sees the
/// repo-default `require_remote_on_claim` set to true. Mitigates the
/// "surprise: my claims are hitting the network" risk for new devs
/// onboarding to a project. Subsequent invocations are silent.
///
/// Writing the marker is best-effort: if `.balls/local/` isn't
/// writable, we'd rather repeat the hint than fail the prime.
pub fn notify_repo_default_once(store: &Store, policy: ClaimPolicy) {
if !policy.from_repo_default || !policy.require_remote {
return;
}
let marker = seen_marker_path(store);
if marker.exists() {
return;
}
let _ = writeln!(
std::io::stderr(),
"this repo requests synced claims (remote default; override with --no-sync \
or `.balls/local/config.json` `require_remote_on_claim: false`)"
);
if let Some(parent) = marker.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&marker, "shown\n");
}
#[cfg(test)]
#[path = "policy_tests.rs"]
mod tests;