Skip to content

Commit 860a102

Browse files
committed
sync: add home branch mode
1 parent c7fc311 commit 860a102

7 files changed

Lines changed: 442 additions & 49 deletions

File tree

docs/commands/jj.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ This keeps jj aligned with Git remotes while you work locally in jj.
119119
If you keep a long-lived personal branch on top of trunk, set `jj.home_branch` and treat it as
120120
your integration branch.
121121

122+
When the current branch matches that home branch, plain `f sync` also switches into home-branch
123+
mode and syncs `origin/<default-branch>` into it instead of using the normal tracking pull.
124+
122125
Then use short-lived `review/*` or `codex/*` branches on top of that home branch for task-specific
123126
work. `f status` is optimized to make that shape visible.
124127

docs/commands/sync.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Use this block when passing `f sync` behavior to another agent:
1313
- Default behavior: pull/sync only; push is off unless `--push`.
1414
- Defaults: `--stash=true`, `--fix=true`.
1515
- Modes: uses jj flow in healthy colocated workspaces, falls back to plain git when needed.
16+
- Home-branch mode: when the current branch matches `jj.home_branch`, `f sync` skips the normal tracking pull and syncs `origin/<default-branch>` into that branch.
1617
- Push target: configured `[git].remote` first, then standard fallback behavior.
1718
- Clipboard output: synced commit list is copied only when remote commit ranges are detected (typically jj fetch path).
1819
- Conflict note: jj can finish with unresolved conflicts and prints `jj resolve` guidance.
@@ -73,6 +74,25 @@ If an `upstream` remote exists (fork workflow), fetches and merges upstream chan
7374

7475
If no `upstream` remote but on a feature branch, syncs from `origin/<default-branch>` (e.g. `origin/main`) into the current branch.
7576

77+
### Home-branch mode
78+
79+
If the current branch matches the resolved `jj.home_branch`, Flow treats that branch as your
80+
long-lived integration branch.
81+
82+
In that mode, `f sync`:
83+
84+
- skips the normal tracking-branch pull
85+
- leaves branch tracking config untouched
86+
- syncs `origin/<default-branch>` into the home branch
87+
- keeps all non-home branches on the existing sync behavior
88+
89+
Home-branch resolution order is:
90+
91+
1. `<repo>/flow.toml` `jj.home_branch`
92+
2. `~/.config/flow/flow.toml` `jj.home_branch`
93+
3. basename of `$HOME`
94+
4. `USER` or `USERNAME`
95+
7696
### Step 4: Push (optional)
7797

7898
Only when `--push` is passed:
@@ -120,6 +140,9 @@ Set in `flow.toml` under `[git]`:
120140
```toml
121141
[git]
122142
remote = "origin" # default push remote
143+
144+
[jj]
145+
home_branch = "alice" # optional long-lived personal integration branch
123146
```
124147

125148
### Fork push

docs/flow-toml-spec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ fr = "f run"
158158
- `[commit.skill_gate]`: optional required-skill policy for `f commit`; can enforce presence and minimum skill versions.
159159
- `[invariants]`: optional policy checks for forbidden patterns, dependency allowlists, terminology context, and file-size limits. `mode = "block"` makes invariant warnings fail `f invariants` and commit-time invariant gate checks.
160160
- `[git].remote`: preferred writable remote used by `f commit`/`f sync --push` (and jj remote defaults). Fallback order is `[git].remote`, then legacy `[jj].remote`, then `origin`.
161+
- `[jj].home_branch`: optional long-lived personal integration branch. When the current branch matches it, `f sync` switches into home-branch mode and syncs `origin/<default-branch>` into that branch. Resolution order is repo `flow.toml`, then `~/.config/flow/flow.toml`, then the basename of `$HOME`, then `USER` / `USERNAME`.
161162

162163
## Notes
163164

docs/jj-home-branch-workflow.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Flow supports that model directly through:
77

88
- `f status` for a workflow-aware status view
99
- `jj.home_branch` in `flow.toml`
10+
- `f sync` home-branch mode for pulling `origin/<default-branch>` into the home branch
1011
- `f jj workspace review <branch>` for isolated branch-specific working copies
1112

1213
## Mental model
@@ -28,6 +29,9 @@ default_branch = "main"
2829
home_branch = "alice"
2930
```
3031

32+
For `f sync`, if `jj.home_branch` is omitted, Flow falls back to the basename of `$HOME` and then
33+
`USER` / `USERNAME`.
34+
3135
## Status as the preflight
3236

3337
Before switching branches, creating workspaces, committing, or publishing, run:

src/config.rs

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,34 @@ pub struct JjConfig {
505505
pub review_prefix: Option<String>,
506506
}
507507

508+
impl JjConfig {
509+
pub(crate) fn merge(&mut self, other: JjConfig) {
510+
if self.default_branch.is_none() {
511+
self.default_branch = other.default_branch;
512+
}
513+
if self.remote.is_none() {
514+
self.remote = other.remote;
515+
}
516+
if self.auto_track.is_none() {
517+
self.auto_track = other.auto_track;
518+
}
519+
if self.home_branch.is_none() {
520+
self.home_branch = other.home_branch;
521+
}
522+
if self.review_prefix.is_none() {
523+
self.review_prefix = other.review_prefix;
524+
}
525+
}
526+
527+
fn is_empty(&self) -> bool {
528+
self.default_branch.is_none()
529+
&& self.remote.is_none()
530+
&& self.auto_track.is_none()
531+
&& self.home_branch.is_none()
532+
&& self.review_prefix.is_none()
533+
}
534+
}
535+
508536
/// Git workflow config.
509537
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
510538
pub struct GitConfig {
@@ -2489,6 +2517,8 @@ fn merge_config(base: &mut Config, other: Config) {
24892517
}
24902518
if base.jj.is_none() {
24912519
base.jj = other.jj;
2520+
} else if let (Some(base_jj), Some(other_jj)) = (base.jj.as_mut(), other.jj) {
2521+
base_jj.merge(other_jj);
24922522
}
24932523
if base.everruns.is_none() {
24942524
base.everruns = other.everruns;
@@ -2594,6 +2624,87 @@ fn preferred_git_remote_from_cfg(cfg: &Config) -> Option<String> {
25942624
.and_then(|jj_cfg| first_non_empty_remote(jj_cfg.remote.as_deref()))
25952625
}
25962626

2627+
fn load_jj_config_from_path(path: &Path) -> Option<JjConfig> {
2628+
load(path).ok()?.jj
2629+
}
2630+
2631+
pub fn effective_jj_config_for_repo(repo_root: &Path) -> Option<JjConfig> {
2632+
let local_config = repo_root.join("flow.toml");
2633+
let local = if local_config.exists() {
2634+
load_jj_config_from_path(&local_config)
2635+
} else {
2636+
None
2637+
};
2638+
2639+
let global_config = default_config_path();
2640+
let global = if global_config.exists() {
2641+
load_jj_config_from_path(&global_config)
2642+
} else {
2643+
None
2644+
};
2645+
2646+
let merged = match (local, global) {
2647+
(Some(mut local), Some(global)) => {
2648+
local.merge(global);
2649+
Some(local)
2650+
}
2651+
(Some(local), None) => Some(local),
2652+
(None, Some(global)) => Some(global),
2653+
(None, None) => None,
2654+
}?;
2655+
2656+
(!merged.is_empty()).then_some(merged)
2657+
}
2658+
2659+
pub fn configured_home_branch_for_repo(repo_root: &Path) -> Option<String> {
2660+
effective_jj_config_for_repo(repo_root)
2661+
.and_then(|cfg| cfg.home_branch)
2662+
.and_then(|value| normalized_branch_name(&value))
2663+
}
2664+
2665+
fn normalized_branch_name(value: &str) -> Option<String> {
2666+
let trimmed = value.trim();
2667+
if trimmed.is_empty() {
2668+
None
2669+
} else {
2670+
Some(trimmed.to_string())
2671+
}
2672+
}
2673+
2674+
fn inferred_home_branch_from_sources(
2675+
home_dir: Option<&Path>,
2676+
user: Option<&str>,
2677+
username: Option<&str>,
2678+
) -> Option<String> {
2679+
home_dir
2680+
.and_then(|path| path.file_name())
2681+
.and_then(|name| name.to_str())
2682+
.and_then(normalized_branch_name)
2683+
.or_else(|| user.and_then(normalized_branch_name))
2684+
.or_else(|| username.and_then(normalized_branch_name))
2685+
}
2686+
2687+
pub fn resolved_home_branch_for_repo(
2688+
repo_root: &Path,
2689+
default_branch: Option<&str>,
2690+
) -> Option<String> {
2691+
if let Some(configured) = configured_home_branch_for_repo(repo_root) {
2692+
return Some(configured);
2693+
}
2694+
2695+
let home_dir = std::env::var_os("HOME").map(PathBuf::from);
2696+
let derived = inferred_home_branch_from_sources(
2697+
home_dir.as_deref(),
2698+
std::env::var("USER").ok().as_deref(),
2699+
std::env::var("USERNAME").ok().as_deref(),
2700+
)?;
2701+
let default_branch = default_branch.and_then(normalized_branch_name);
2702+
if default_branch.as_deref() == Some(derived.as_str()) {
2703+
return None;
2704+
}
2705+
Some(derived)
2706+
}
2707+
25972708
/// Resolve the preferred writable git remote for a repository.
25982709
///
25992710
/// Precedence:
@@ -2643,7 +2754,7 @@ pub fn load_or_default<P: AsRef<Path>>(path: P) -> Config {
26432754
#[cfg(test)]
26442755
mod tests {
26452756
use super::*;
2646-
use std::path::PathBuf;
2757+
use std::path::{Path, PathBuf};
26472758
use tempfile::tempdir;
26482759

26492760
fn fixture_path(relative: &str) -> PathBuf {
@@ -3284,6 +3395,76 @@ remote = "myflow-i"
32843395
);
32853396
}
32863397

3398+
#[test]
3399+
fn jj_config_merge_fills_missing_fields_only() {
3400+
let mut local = JjConfig {
3401+
default_branch: Some("main".to_string()),
3402+
auto_track: Some(true),
3403+
..Default::default()
3404+
};
3405+
let global = JjConfig {
3406+
default_branch: Some("trunk".to_string()),
3407+
remote: Some("origin".to_string()),
3408+
auto_track: Some(false),
3409+
home_branch: Some("nikiv".to_string()),
3410+
review_prefix: Some("review".to_string()),
3411+
};
3412+
3413+
local.merge(global);
3414+
3415+
assert_eq!(local.default_branch.as_deref(), Some("main"));
3416+
assert_eq!(local.remote.as_deref(), Some("origin"));
3417+
assert_eq!(local.auto_track, Some(true));
3418+
assert_eq!(local.home_branch.as_deref(), Some("nikiv"));
3419+
assert_eq!(local.review_prefix.as_deref(), Some("review"));
3420+
}
3421+
3422+
#[test]
3423+
fn inferred_home_branch_prefers_home_dir_basename() {
3424+
let home = Path::new("/Users/nikitavoloboev");
3425+
3426+
assert_eq!(
3427+
inferred_home_branch_from_sources(Some(home), Some("other-user"), Some("third-user")),
3428+
Some("nikitavoloboev".to_string())
3429+
);
3430+
}
3431+
3432+
#[test]
3433+
fn inferred_home_branch_falls_back_to_user_then_username() {
3434+
assert_eq!(
3435+
inferred_home_branch_from_sources(None, Some("nikiv"), Some("ignored")),
3436+
Some("nikiv".to_string())
3437+
);
3438+
assert_eq!(
3439+
inferred_home_branch_from_sources(None, None, Some("windows-user")),
3440+
Some("windows-user".to_string())
3441+
);
3442+
}
3443+
3444+
#[test]
3445+
fn inferred_home_branch_ignores_blank_values() {
3446+
assert_eq!(
3447+
inferred_home_branch_from_sources(None, Some(" "), Some("")),
3448+
None
3449+
);
3450+
}
3451+
3452+
#[test]
3453+
fn resolved_home_branch_for_repo_prefers_repo_config() {
3454+
let dir = tempdir().expect("tempdir");
3455+
let repo_root = dir.path();
3456+
fs::write(
3457+
repo_root.join("flow.toml"),
3458+
"[jj]\nhome_branch = \"nikiv\"\n",
3459+
)
3460+
.expect("write flow.toml");
3461+
3462+
assert_eq!(
3463+
resolved_home_branch_for_repo(repo_root, Some("main")),
3464+
Some("nikiv".to_string())
3465+
);
3466+
}
3467+
32873468
#[test]
32883469
fn analytics_config_parses() {
32893470
let toml = r#"

src/jj.rs

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,10 +1731,7 @@ fn current_workspace_name(workspace_root: &Path) -> Result<String> {
17311731
}
17321732

17331733
fn configured_home_branch(repo_root: &Path) -> Option<String> {
1734-
load_jj_config(repo_root)
1735-
.and_then(|cfg| cfg.home_branch)
1736-
.map(|value| value.trim().to_string())
1737-
.filter(|value| !value.is_empty())
1734+
config::configured_home_branch_for_repo(repo_root)
17381735
}
17391736

17401737
fn git_current_branch(repo_root: &Path) -> Option<String> {
@@ -2316,23 +2313,7 @@ fn auto_track_enabled(repo_root: &Path) -> bool {
23162313
}
23172314

23182315
fn load_jj_config(repo_root: &Path) -> Option<config::JjConfig> {
2319-
let local = repo_root.join("flow.toml");
2320-
if local.exists() {
2321-
if let Ok(cfg) = config::load(&local) {
2322-
if cfg.jj.is_some() {
2323-
return cfg.jj;
2324-
}
2325-
}
2326-
}
2327-
let global = config::default_config_path();
2328-
if global.exists() {
2329-
if let Ok(cfg) = config::load(&global) {
2330-
if cfg.jj.is_some() {
2331-
return cfg.jj;
2332-
}
2333-
}
2334-
}
2335-
None
2316+
config::effective_jj_config_for_repo(repo_root)
23362317
}
23372318

23382319
fn review_workspace_name(branch: &str) -> String {

0 commit comments

Comments
 (0)