Integrated repository, worktree, git stacker, and project manager
0

Configure Feed

Select the types of activity you want to include in your feed.

stacker: add `pm stacker sync --hard` to bypass patch-id dedup

Default sync builds the cherry-pick list with `git rev-list --cherry-pick
--right-only <parent>...<child>` — the same patch-id heuristic `git
rebase` uses to drop already-applied commits. When a parent branch is
rewritten in place (e.g. a stacker branch is re-edited and its commits
get new patch-ids while keeping the same logical change), the child's
copy of the now-superseded commit no longer matches the parent by
patch-id, and sync replays the duplicate, hitting avoidable conflicts.

`--hard` is the explicit escape hatch: instead of the patch-id walk, it
takes the recorded `managed_base_commit` from the stacker DB and
cherry-picks exactly `<managed_base>..<HEAD>` — the commits the child
itself added since its last sync. The flag flows through the existing
sync entry point, persists on `OperationState` so `pm stacker continue`
can resume a paused `--hard` sync without re-passing the flag, and
applies to every branch in a multi-branch (downstream_sync) walk. The
new column is migrated lazily on existing DBs.

Jordan Isaacs (Apr 30, 2026, 12:26 AM UTC) ab2ea3fe e937b086

+198 -23
+1 -1
README.md
··· 36 36 pm pool add <repo> # mint a slot 37 37 38 38 pm stacker create <branch> [--on current|parent|<branch>] [--copy <b>] [--replace] [--no-checkout] 39 - pm stacker sync [<branch>] [-c|-a] [--skip-ancestors] [--skip-descendants] [--from <b>] [--continue | --abort] 39 + pm stacker sync [<branch>] [-c|-a] [--skip-ancestors] [--skip-descendants] [--from <b>] [--hard] [--continue | --abort] 40 40 pm stacker push [<branch>] [-c|-a] [--only] [--skip-ancestors] [--skip-descendants] [--publish|--draft] [--create-pr true|false] 41 41 pm stacker ls [<branch>] [-c|-a] [--details none|status|status-counts|all] [--json] 42 42 pm stacker remove [<branch>] [--keep-branch] [--parent] [--force]
+129
tests/stacker/test_sync.py
··· 254 254 target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="b") 255 255 with pytest.raises(stacker_git.GitError, match="--from"): 256 256 service.sync(target, ScopeSpec(scope="current", from_branch="nonexistent")) 257 + 258 + 259 + # --- --hard flag and parent-rewrite detection ------------------------------- 260 + 261 + 262 + def _rewrite_main(repo_path: Path, before: str, content: str, message: str) -> str: 263 + """Reset main to `before` and lay down a fresh commit. Returns its sha. 264 + 265 + Simulates a non-fast-forward parent rewrite: the previous tip is no 266 + longer reachable from main. 267 + """ 268 + stacker_git.git(repo_path, "reset", "--hard", before) 269 + return commit_file(repo_path, "shared.txt", content, message) 270 + 271 + 272 + def test_sync_hard_replays_after_rewrite( 273 + stacker_repo: tuple[str, Path], 274 + three_slots: list[slot_mod.Slot], 275 + service: StackerService, 276 + ) -> None: 277 + """--hard replays exactly the child's own commits after a parent rewrite.""" 278 + repo_name, repo_path = stacker_repo 279 + feature_slot = three_slots[0] 280 + main_before = stacker_git.rev_parse(repo_path, "HEAD") 281 + commit_file(repo_path, "shared.txt", "main v1\n", "main: v1") 282 + _initialize(service, repo_name, feature_slot, "feature-hard") 283 + commit_file(feature_slot.path, "feat.txt", "feat\n", "feat: own commit") 284 + 285 + service.sync(SelectorTarget(repo_name=repo_name, branch="feature-hard")) 286 + 287 + # Rewrite main with a non-conflicting change so the cherry-pick of the 288 + # child's own commit applies cleanly. 289 + main_v1_prime = _rewrite_main(repo_path, main_before, "main prime\n", "main: v1'") 290 + 291 + result = service.sync( 292 + SelectorTarget(repo_name=repo_name, branch="feature-hard"), hard=True, 293 + ) 294 + assert "Sync complete." in result 295 + tracked = service.db.get_branch(repo_name, "feature-hard") 296 + assert tracked is not None 297 + assert tracked.managed_base_commit == main_v1_prime 298 + assert tracked.last_clean_head is not None 299 + # Branch HEAD should be `main_v1_prime + feat commit` — verify by walking 300 + # one parent back from the head. 301 + head_parent = stacker_git.rev_parse(feature_slot.path, "HEAD~1") 302 + assert head_parent == main_v1_prime 303 + 304 + 305 + def test_sync_hard_equivalent_to_default_on_clean_advance( 306 + stacker_repo: tuple[str, Path], 307 + three_slots: list[slot_mod.Slot], 308 + service: StackerService, 309 + ) -> None: 310 + """When the parent is fast-forwarded, --hard and default produce the same outcome.""" 311 + repo_name, repo_path = stacker_repo 312 + feature_slot = three_slots[0] 313 + _initialize(service, repo_name, feature_slot, "feature-eq") 314 + commit_file(feature_slot.path, "feat.txt", "feat\n", "feat: own commit") 315 + new_main = _advance_main(repo_path) 316 + 317 + result = service.sync( 318 + SelectorTarget(repo_name=repo_name, branch="feature-eq"), hard=True, 319 + ) 320 + assert "Sync complete." in result 321 + tracked = service.db.get_branch(repo_name, "feature-eq") 322 + assert tracked is not None 323 + assert tracked.managed_base_commit == new_main 324 + 325 + 326 + def test_sync_hard_continue_persists( 327 + stacker_repo: tuple[str, Path], 328 + three_slots: list[slot_mod.Slot], 329 + service: StackerService, 330 + ) -> None: 331 + """A paused --hard sync resumes via plain `continue` — flag survives in the DB.""" 332 + repo_name, repo_path = stacker_repo 333 + feature_slot = three_slots[0] 334 + main_before = stacker_git.rev_parse(repo_path, "HEAD") 335 + commit_file(repo_path, "shared.txt", "main v1\n", "main: v1") 336 + _initialize(service, repo_name, feature_slot, "feature-pause") 337 + # Child touches shared.txt — cherry-pick onto rewritten main will conflict. 338 + commit_file(feature_slot.path, "shared.txt", "child v1\n", "feat: shared") 339 + 340 + service.sync(SelectorTarget(repo_name=repo_name, branch="feature-pause")) 341 + 342 + # Rewrite main with a conflicting change on shared.txt. 343 + _rewrite_main(repo_path, main_before, "main prime\n", "main: v1'") 344 + 345 + paused = service.sync( 346 + SelectorTarget(repo_name=repo_name, branch="feature-pause"), hard=True, 347 + ) 348 + assert "paused" in paused.lower() 349 + op = service.db.get_operation(repo_name) 350 + assert op is not None 351 + assert op.hard is True 352 + 353 + # Resolve by taking the child's version. 354 + (feature_slot.path / "shared.txt").write_text("child v1\n") 355 + stacker_git.git(feature_slot.path, "add", "shared.txt") 356 + 357 + finished = service.continue_operation(repo_name) 358 + assert "Sync complete." in finished 359 + assert service.db.get_operation(repo_name) is None 360 + 361 + 362 + def test_sync_hard_downstream_propagates( 363 + tracked_stack: TrackedStack, 364 + service: StackerService, 365 + ) -> None: 366 + """--hard applies to every branch in a multi-branch (downstream_sync) walk.""" 367 + main_before = stacker_git.rev_parse(tracked_stack.repo_path, "HEAD") 368 + commit_file(tracked_stack.repo_path, "shared.txt", "main v1\n", "main: v1") 369 + 370 + target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="d") 371 + service.sync(target) 372 + 373 + # Rewrite main non-conflictingly. 374 + new_main_prime = _rewrite_main( 375 + tracked_stack.repo_path, main_before, "main prime\n", "main: v1'", 376 + ) 377 + 378 + result = service.sync(target, hard=True) 379 + assert "Sync complete." in result 380 + 381 + # All branches should be re-rooted on the rewritten main, which means 382 + # `a`'s managed_base now equals new_main_prime. 383 + a = service.db.get_branch(tracked_stack.repo_name, "a") 384 + assert a is not None 385 + assert a.managed_base_commit == new_main_prime
+14 -2
src/project_manager/stacker/db.py
··· 56 56 commit_list_json TEXT, 57 57 next_commit_index INTEGER NOT NULL DEFAULT 0, 58 58 error_message TEXT, 59 + hard INTEGER NOT NULL DEFAULT 0, 59 60 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 60 61 ); 61 62 ··· 77 78 def connect(self) -> Iterator[sqlite3.Connection]: 78 79 with sqlite_db.transaction(self.db_path, schema=_SCHEMA, row_factory=sqlite3.Row) as conn: 79 80 _migrate_pr_url_to_pr_state(conn) 81 + _migrate_operations_add_hard(conn) 80 82 yield conn 81 83 82 84 def upsert_branch(self, tracked: TrackedBranch) -> None: ··· 196 198 INSERT INTO operations ( 197 199 repo_name, op_type, status, branch, parent_branch, root_branch, 198 200 queue_json, current_index, start_head, target_parent_head, 199 - commit_list_json, next_commit_index, error_message, updated_at 200 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 201 + commit_list_json, next_commit_index, error_message, hard, updated_at 202 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 201 203 ON CONFLICT(repo_name) DO UPDATE SET 202 204 op_type = excluded.op_type, 203 205 status = excluded.status, ··· 211 213 commit_list_json = excluded.commit_list_json, 212 214 next_commit_index = excluded.next_commit_index, 213 215 error_message = excluded.error_message, 216 + hard = excluded.hard, 214 217 updated_at = CURRENT_TIMESTAMP 215 218 """, 216 219 ( ··· 227 230 json.dumps(op.commit_list), 228 231 op.next_commit_index, 229 232 op.error_message, 233 + int(op.hard), 230 234 ), 231 235 ) 232 236 ··· 379 383 conn.execute("ALTER TABLE tracked_branches DROP COLUMN pr_url") 380 384 381 385 386 + def _migrate_operations_add_hard(conn: sqlite3.Connection) -> None: 387 + cols = {row["name"] for row in conn.execute("PRAGMA table_info(operations)")} 388 + if "hard" in cols: 389 + return 390 + conn.execute("ALTER TABLE operations ADD COLUMN hard INTEGER NOT NULL DEFAULT 0") 391 + 392 + 382 393 def _row_to_operation(row: sqlite3.Row) -> OperationState: 383 394 return OperationState( 384 395 repo_name=row["repo_name"], ··· 394 405 commit_list=json.loads(row["commit_list_json"]) if row["commit_list_json"] else [], 395 406 next_commit_index=row["next_commit_index"], 396 407 error_message=row["error_message"], 408 + hard=bool(row["hard"]), 397 409 ) 398 410 399 411
+1
src/project_manager/stacker/models.py
··· 125 125 commit_list: list[str] = field(default_factory=list) 126 126 next_commit_index: int = 0 127 127 error_message: str | None = None 128 + hard: bool = False
+3 -2
src/project_manager/stacker/service.py
··· 180 180 # --- sync / push / repair --- 181 181 182 182 def sync( 183 - self, target: SelectorTarget, spec: ScopeSpec = DEFAULT_SCOPE 183 + self, target: SelectorTarget, spec: ScopeSpec = DEFAULT_SCOPE, 184 + *, hard: bool = False, 184 185 ) -> str: 185 - return sync_ops.sync(self._ctx, target, spec) 186 + return sync_ops.sync(self._ctx, target, spec, hard=hard) 186 187 187 188 def absorb(self, target: SelectorTarget) -> str: 188 189 return absorb_ops.absorb(self._ctx, target)
+1
integrations/claude-code/skills/pm-stacker-workflow/SKILL.md
··· 63 63 | Command | Description | 64 64 |---|---| 65 65 | `pm stacker sync [--branch <b>] [--all] [--from <b>] [--skip-ancestors\|--skip-descendants]` | Cherry-pick the branch (and lineage by default) onto its parent | 66 + | `pm stacker sync --hard` | Cherry-pick exactly the commits added since last sync (`<managed_base>..HEAD`). Use when the parent was rewritten in place and patch-id dedup can't see the duplicate. | 66 67 | `pm stacker sync --continue` | Resume after resolving a cherry-pick conflict | 67 68 | `pm stacker sync --abort` | Roll back the in-progress sync | 68 69 | `pm stacker push [--branch <b>] [--all] [--only] [--draft\|--publish] [--create-pr {true\|false}]` | Force-push and create/update GitHub PRs. By default leaf=published, others=draft. |
+22 -9
src/project_manager/stacker/cherry_pick/driver.py
··· 28 28 29 29 30 30 def sync_plan( 31 - ctx: StackerCtx, tracked: TrackedBranch, slot_path: Path 31 + ctx: StackerCtx, tracked: TrackedBranch, slot_path: Path, 32 + *, hard: bool = False, 32 33 ) -> tuple[str, str, list[str]]: 33 34 repo_path = ctx.paths.repo(tracked.parent_repo_name) 34 35 parent_head = git.rev_parse(repo_path, tracked.parent_branch) 35 36 start_head = git.rev_parse(slot_path, "HEAD") 36 - # Drop commits whose patch is already on the parent (mirrors `git rebase`'s 37 - # default). Keeps absorb→sync flows conflict-free and makes the parent the 38 - # source of truth whenever both sides carry the same logical change. 39 - commit_list = git.rev_list_picking(slot_path, parent_head, start_head) 37 + if hard: 38 + # Pure replay: cherry-pick exactly the commits the child added on top 39 + # of its recorded base. Bypasses the patch-id walk, which can't see 40 + # that a parent rewritten in-place is "the same change" as the child's 41 + # now-stale duplicate (different diffs → different patch-ids). 42 + commit_list = git.rev_list( 43 + slot_path, f"{tracked.managed_base_commit}..{start_head}" 44 + ) 45 + else: 46 + # Drop commits whose patch is already on the parent (mirrors `git rebase`'s 47 + # default). Keeps absorb→sync flows conflict-free and makes the parent the 48 + # source of truth whenever both sides carry the same logical change. 49 + commit_list = git.rev_list_picking(slot_path, parent_head, start_head) 40 50 return parent_head, start_head, commit_list 41 51 42 52 43 - def prepare_local_operation( 53 + def prepare_local_operation( # noqa: PLR0913 — internal helper, kwargs only 44 54 ctx: StackerCtx, 45 55 tracked: TrackedBranch, 46 56 *, 47 57 op_type: str, 48 58 slot_path: Path, 49 59 logs: list[str], 60 + hard: bool = False, 50 61 ) -> OperationState: 51 - parent_head, start_head, commit_list = sync_plan(ctx, tracked, slot_path) 62 + parent_head, start_head, commit_list = sync_plan(ctx, tracked, slot_path, hard=hard) 52 63 op = ctx.db.get_operation(tracked.repo_name) 53 64 if op is None: 54 65 op = OperationState( ··· 61 72 op.target_parent_head = parent_head 62 73 op.commit_list = commit_list 63 74 op.next_commit_index = 0 75 + op.hard = hard 64 76 ctx.db.put_operation(op) 65 77 fmt.record( 66 78 ctx, ··· 184 196 # would orphan the just-claimed slot, since the caller's outer 185 197 # finally only sees the new slot after this returns. 186 198 try: 187 - parent_head, _, _ = sync_plan(ctx, tracked, acquired.path) 199 + parent_head, _, _ = sync_plan(ctx, tracked, acquired.path, hard=op.hard) 188 200 if parent_head == tracked.managed_base_commit: 189 201 child_label = selectors.selector_for(tracked.repo_name, tracked.branch) 190 202 parent_label = selectors.selector_for( ··· 201 213 worktree.release_if_clean(ctx, acquired) 202 214 return None 203 215 prepare_local_operation( 204 - ctx, tracked, op_type="downstream_sync", slot_path=acquired.path, logs=logs 216 + ctx, tracked, op_type="downstream_sync", slot_path=acquired.path, 217 + logs=logs, hard=op.hard, 205 218 ) 206 219 except BaseException: 207 220 worktree.release_if_clean(ctx, acquired)
+18 -4
src/project_manager/stacker/commands/sync.py
··· 5 5 6 6 from project_manager import config 7 7 from project_manager.cli._shared import StackerScope 8 + from project_manager.stacker import git 8 9 9 10 from . import _common, stacker_app 10 11 ··· 22 23 bool, 23 24 Parameter(negative="", help="abort the paused op"), 24 25 ] = False, 26 + hard: Annotated[ 27 + bool, 28 + Parameter( 29 + negative="", 30 + help="cherry-pick exactly the commits added since last sync; " 31 + "skip patch-id dedup. Use when the parent was rewritten in place.", 32 + ), 33 + ] = False, 25 34 ) -> int: 26 35 """Cherry-pick branches onto their parents. 27 36 28 37 --continue / --abort are shortcuts for `pm stacker continue` / `abort`. 38 + --hard replays only the commits the child added on top of its recorded 39 + base — bypasses patch-id dedup so a rewritten parent doesn't cause a 40 + superseded duplicate to be replayed. 29 41 """ 30 42 paths = config.load() 31 43 svc = _common.service(paths) 32 - if continue_: 33 - return _common.emit(svc.continue_operation(_common.resolve_repo(scope.repo, paths))) 34 - if abort: 44 + if continue_ or abort: 45 + if hard: 46 + raise git.GitError("--hard cannot be combined with --continue or --abort.") 47 + if continue_: 48 + return _common.emit(svc.continue_operation(_common.resolve_repo(scope.repo, paths))) 35 49 return _common.emit(svc.abort_operation(_common.resolve_repo(scope.repo, paths))) 36 50 target = _common.target(scope.repo, branch, paths) 37 - return _common.emit(svc.sync(target, _common.scope_spec(scope))) 51 + return _common.emit(svc.sync(target, _common.scope_spec(scope), hard=hard))
+9 -5
src/project_manager/stacker/ops/sync.py
··· 23 23 24 24 25 25 def sync( 26 - ctx: StackerCtx, target: SelectorTarget, spec: ScopeSpec = DEFAULT_SCOPE 26 + ctx: StackerCtx, target: SelectorTarget, spec: ScopeSpec = DEFAULT_SCOPE, 27 + *, hard: bool = False, 27 28 ) -> str: 28 29 """Cherry-pick a set of tracked branches onto their parents. 29 30 ··· 41 42 if not resolved: 42 43 return "No tracked branches to sync." 43 44 if len(resolved) == 1: 44 - return _sync_one(ctx, resolved[0]) 45 + return _sync_one(ctx, resolved[0], hard=hard) 45 46 ctx.db.put_operation( 46 47 OperationState( 47 48 repo_name=target.repo_name, ··· 50 51 root_branch=resolved[0].branch, 51 52 queue=[b.branch for b in resolved], 52 53 current_index=0, 54 + hard=hard, 53 55 ) 54 56 ) 55 57 return cp_driver.run_until_pause_or_finish(ctx, target.repo_name, logs=[]) 56 58 57 59 58 - def _sync_one(ctx: StackerCtx, tracked: TrackedBranch) -> str: 60 + def _sync_one(ctx: StackerCtx, tracked: TrackedBranch, *, hard: bool = False) -> str: 59 61 """Single-branch sync path: local_sync op with up-to-date short-circuit.""" 60 62 ctx.db.put_operation( 61 63 OperationState( ··· 64 66 status="running", 65 67 branch=tracked.branch, 66 68 parent_branch=tracked.parent_branch, 69 + hard=hard, 67 70 ) 68 71 ) 69 72 # The context manager releases the slot iff the worktree is clean on ··· 72 75 # sees `CHERRY_PICK_HEAD`) so `pm stacker continue` can resume. 73 76 try: 74 77 with worktree.acquired_for_op(ctx, tracked.repo_name, tracked.branch) as acquired: 75 - parent_head, _, _ = cp_driver.sync_plan(ctx, tracked, acquired.path) 78 + parent_head, _, _ = cp_driver.sync_plan(ctx, tracked, acquired.path, hard=hard) 76 79 if parent_head == tracked.managed_base_commit: 77 80 ctx.db.clear_operation(tracked.repo_name) 78 81 child_label = selectors.selector_for(tracked.repo_name, tracked.branch) ··· 86 89 ensure_syncable(acquired.path) 87 90 logs: list[str] = [] 88 91 cp_driver.prepare_local_operation( 89 - ctx, tracked, op_type="local_sync", slot_path=acquired.path, logs=logs 92 + ctx, tracked, op_type="local_sync", slot_path=acquired.path, 93 + logs=logs, hard=hard, 90 94 ) 91 95 return cp_driver.run_until_pause_or_finish( 92 96 ctx, tracked.repo_name, acquired, logs=logs,