From 25aad2f04ab8193d2621c8577558aa7b9a4c602d Mon Sep 17 00:00:00 2001 From: Ancelin SERRE Date: Thu, 22 Oct 2026 18:09:46 +0200 Subject: [PATCH] fix: a lane's branch pushes to itself, not to the default branch The launcher created task/ from the remote's default branch with `git worktree add -b`, and git set that default branch as its upstream. In a lane worktree a plain `git push` was then refused, as the tiroir pilot found on foundation-001, and with push.default=upstream it would have pushed the task's work to the default branch. The launcher now creates the branch with --no-track, and a won claim sets /task/ as the upstream of the branch it was run from, which also covers a developer claiming by hand. Co-Authored-By: Claude Opus 5.5 --- .../references/conventions.md | 5 +++++ .../scripts/launch-lanes.sh | 4 +++- .agents/skills/delivery-status/SKILL.md | 3 ++- .../skills/delivery-status/scripts/claim.sh | 10 +++++++++ CHANGELOG.md | 4 ++++ tests/unit/test_delivery_status_scripts.py | 21 +++++++++++++++++++ tests/unit/test_launch_lanes_script.py | 17 +++++++++++++++ 7 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.agents/skills/delivery-orchestrator/references/conventions.md b/.agents/skills/delivery-orchestrator/references/conventions.md index cd5fd00..676d8c0 100644 --- a/.agents/skills/delivery-orchestrator/references/conventions.md +++ b/.agents/skills/delivery-orchestrator/references/conventions.md @@ -236,6 +236,11 @@ push of the current commit is **not** a claim: when the branch already points at that commit, git resolves the push as a no-op and exits 0, and the second developer reads success on a task somebody else holds. +Run from the branch `task/`, a won claim also makes `/task/` +that branch's upstream. A branch started from the remote's default branch +tracks it otherwise, and a plain `git push` is refused, or, with +`push.default=upstream`, lands on the default branch. + Exit codes: `0` claim won, `2` already held, `1` usage or environment error. ## Derived status diff --git a/.agents/skills/delivery-orchestrator/scripts/launch-lanes.sh b/.agents/skills/delivery-orchestrator/scripts/launch-lanes.sh index c024960..bf22604 100644 --- a/.agents/skills/delivery-orchestrator/scripts/launch-lanes.sh +++ b/.agents/skills/delivery-orchestrator/scripts/launch-lanes.sh @@ -418,7 +418,9 @@ while IFS= read -r task_id <&3; do base="$(base_for "$repo_dir")" || { printf 'SKIP\t%s\tcannot fetch the default branch of %s; a lane must not start from a stale base\n' "$task_id" "$repo_name"; continue; } worktree_abs="$(absolute "$worktree")" - git -C "$repo_dir" worktree add --quiet -b "$branch" "$worktree_abs" "$base" 2>/dev/null || + # --no-track: the base is the remote's default branch, and a task branch that + # tracked it would push there. The claim sets its own branch as upstream. + git -C "$repo_dir" worktree add --quiet --no-track -b "$branch" "$worktree_abs" "$base" 2>/dev/null || { printf 'SKIP\t%s\tcould not create worktree on %s\n' "$task_id" "$branch"; continue; } # Claim from inside the worktree. Losing the claim is normal, not an error: diff --git a/.agents/skills/delivery-status/SKILL.md b/.agents/skills/delivery-status/SKILL.md index 722617e..65a1f99 100644 --- a/.agents/skills/delivery-status/SKILL.md +++ b/.agents/skills/delivery-status/SKILL.md @@ -70,7 +70,8 @@ developer can ask rather than duplicate the work. Run the claim from the lane's worktree, on the branch `task/`. On success the local branch is advanced to the claim commit so the lane builds on top of -it. A worktree with uncommitted changes is never reset; the script reports that +it, and its upstream is set to the claimed branch, so a plain `git push` goes +there rather than to the branch it was started from. A worktree with uncommitted changes is never reset; the script reports that it left the branch alone. Pass `--remote ` when the shared remote is not `origin`, and diff --git a/.agents/skills/delivery-status/scripts/claim.sh b/.agents/skills/delivery-status/scripts/claim.sh index 2c5b1c0..3f893d4 100644 --- a/.agents/skills/delivery-status/scripts/claim.sh +++ b/.agents/skills/delivery-status/scripts/claim.sh @@ -123,6 +123,16 @@ if [ "$push_rc" -eq 0 ]; then printf 'COMMIT\t%s\n' "$claim_commit" printf 'CLAIMANT\t%s\n' "$claimant" + # The branch was usually created from the default branch on the remote, which + # git then sets as its upstream: a plain push is refused, or with + # push.default=upstream it lands on the default branch. Its upstream is the + # branch just claimed. Set in config, it needs no remote-tracking ref. + if [ "$(git branch --show-current 2>/dev/null || true)" = "$branch" ]; then + git config "branch.$branch.remote" "$remote" + git config "branch.$branch.merge" "$ref" + printf 'UPSTREAM\t%s/%s\n' "$remote" "$branch" + fi + if [ "$advance" -eq 1 ] && [ "$(git branch --show-current 2>/dev/null || true)" = "$branch" ]; then if [ -n "$(git status --porcelain)" ]; then printf 'NOTE\t%s\n' "local branch left in place: worktree has uncommitted changes" diff --git a/CHANGELOG.md b/CHANGELOG.md index 30645ea..aa86bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ tagging it. ## [Unreleased] +- A lane's branch pushes to itself. The launcher no longer lets `task/` + track the default branch it starts from, and a won claim sets + `/task/` as the branch's upstream, so a plain `git push` from a + lane worktree is no longer refused or sent to the default branch. - An install whose skills are split between `.claude/skills/` and `.agents/skills/`, as `npx skills update` leaves after an install naming only `claude-code`, is reported by `where.sh` as `NEXT repair-install`, and diff --git a/tests/unit/test_delivery_status_scripts.py b/tests/unit/test_delivery_status_scripts.py index b54f6d1..29057b7 100644 --- a/tests/unit/test_delivery_status_scripts.py +++ b/tests/unit/test_delivery_status_scripts.py @@ -245,6 +245,27 @@ def test_advances_the_local_task_branch_to_the_claim_commit(self, tmp_path: Path assert first(rows, "ADVANCED") == "task/ingestion-003" assert git(dev, "rev-parse", "HEAD").stdout.strip() == first(rows, "COMMIT") + def test_a_plain_push_goes_to_the_claimed_branch(self, tmp_path: Path, origin: Path) -> None: + """A branch started from the remote default branch tracks it until the claim says otherwise.""" + dev = developer(tmp_path, origin, "devA") + git(dev, "checkout", "-q", "-b", "task/ingestion-003", "--track", "origin/main") + + result = run_claim(["ingestion-003"], cwd=dev) + + assert first(rows_of(result.stdout), "UPSTREAM") == "origin/task/ingestion-003" + (dev / "work.txt").write_text("done\n", encoding="utf-8") + git(dev, "add", "work.txt") + git(dev, "commit", "-q", "-m", "feat: work") + git(dev, "push", "-q") + assert ( + git(dev, "ls-remote", "origin", "refs/heads/task/ingestion-003").stdout.split()[0] + == git(dev, "rev-parse", "HEAD").stdout.strip() + ) + assert ( + git(dev, "ls-remote", "origin", "refs/heads/main").stdout.split()[0] + != git(dev, "rev-parse", "HEAD").stdout.strip() + ) + def test_no_advance_leaves_the_local_branch_alone(self, tmp_path: Path, origin: Path) -> None: dev = self._on_task_branch(tmp_path, origin, "ingestion-003") before = git(dev, "rev-parse", "HEAD").stdout diff --git a/tests/unit/test_launch_lanes_script.py b/tests/unit/test_launch_lanes_script.py index 6813c3d..c63ac62 100644 --- a/tests/unit/test_launch_lanes_script.py +++ b/tests/unit/test_launch_lanes_script.py @@ -574,6 +574,23 @@ def test_starts_the_agent_inside_the_lane_worktree(self, tmp_path: Path) -> None assert recorded.strip(), "the harness was never invoked" assert str(tmp_path / "lanes" / "a-001") in recorded + def test_the_lane_branch_pushes_to_itself(self, tmp_path: Path) -> None: + """A lane started from the default branch must not track it, or a plain push goes astray.""" + origin_path = make_shared_origin(tmp_path) + dev = clone_of(tmp_path, origin_path, "devA") + log = tmp_path / "agent.log" + + result = launch_live( + dev, + ["--claim-script", str(CLAIM), "--worktree-root", str(tmp_path / "lanes"), "a-001"], + fake_harness(tmp_path, log), + log, + ) + + assert result.returncode == OK + upstream = git(tmp_path / "lanes" / "a-001", "rev-parse", "--abbrev-ref", "task/a-001@{upstream}") + assert upstream.stdout.strip() == "origin/task/a-001" + @staticmethod def one_task_remote(tmp_path: Path, tier: str = "build") -> Path: origin_path = make_shared_origin(tmp_path)