Skip to content

Git tab: update a task branch from its base or upstream (#101)#116

Merged
simion merged 1 commit into
simion:mainfrom
Orellius:pr-git-update
Jul 17, 2026
Merged

Git tab: update a task branch from its base or upstream (#101)#116
simion merged 1 commit into
simion:mainfrom
Orellius:pr-git-update

Conversation

@Orellius

Copy link
Copy Markdown
Contributor

Finishes #101. The show current branch + checkout a different branch bullets shipped in #111; this is the title's remaining update/pull/rebase.

A task worktree goes stale as its base moves, and there was no way to catch it up without dropping to the terminal.

What it adds

task_git_update(id, dir_name, mode) with mode = pull | merge | rebase, surfaced as an "Update" section at the top of the existing BranchBar dropdown:

  • Pull - the branch's own upstream. Hidden until the branch has been pushed, since task branches are cut --no-track and genuinely have no upstream yet. (Worth noting: a plain git worktree add -b task origin/main does auto-set upstream to origin/main, which would make "Pull" silently mean "merge base" - the --no-track already in the create path is what keeps this honest.)
  • Merge <base> into this branch / Rebase onto <base> - fetch, then integrate the task's base_branch. Hidden when the base is empty or equals the branch.

Plus a lazy task_git_update_info the dropdown calls on open, rather than putting upstream/base on the 4s-polled task_git_status payload (two extra subprocesses per repo per poll to serve a menu nobody opened).

Mechanics worth a look

  • Network git goes through the hardened fetch path, never a bare git pull. The plain git() helper sets no GIT_TERMINAL_PROMPT=0 and has no timeout, so a git pull through it can block forever on a credential prompt and wedge the spawn_blocking thread. I extracted the hardening already in git_fetch_base into fetch_ref; git_fetch_base is now a thin best-effort wrapper over it, so the task-create path behaves exactly as before (its () return type means fatality can't change by construction). Pull is therefore implemented as fetch + merge upstream.
  • --autostash, not a hand-rolled stash push/pop. git holds the autostash outside stash list and applies it exactly when the op concludes, including after a rebase --continue. Hand-stashing around a conflicting rebase strands the user's work behind a half-finished op.
  • A compound git op can exit 0 and still leave conflicts. When the autostash re-apply collides, git commits the merge, prints Applying autostash resulted in conflicts. Your changes are safe in the stash., retains the stash, and exits 0 with UU markers - and there's no MERGE_HEAD/rebase dir to detect. Reported separately as stash_conflicted with its own message; otherwise we'd tell the user their work was restored while it sat in a stash they were never told about.
  • Classification is by repo state, not by matching git's stderr (locale-dependent): MERGE_HEAD / rebase dir / diff --diff-filter=U, and HEAD sha before-vs-after for up_to_date.
  • Conflicts are left in progress rather than auto-aborted. Every task has a terminal attached, so the user resolves in place; auto-aborting would throw away a resolvable merge. Happy to flip this if you'd rather it abort.
  • Members carry no base_branch of their own, so it's joined from the owning project's member entry by repo_path (with a legacy project_id fallback). Unresolvable degrades to "no menu item" rather than a wrong merge.

Guards

Detached HEAD (git rebase main on a detached HEAD otherwise succeeds and silently rewrites it); a base equal to the branch (repo-root and adopted tasks record their own branch as the base); a removed remote (staying silent there would merge a stale local-tracking ref); and a local branch whose name contains a slash (not mistaken for <remote>/<ref>).

Deliberately out of scope

A rebased branch that was already pushed hits a non-fast-forward rejection on the next push. Nothing is silently overwritten - the user gets git's own rejection - but force-push handling felt like a separate decision rather than something to smuggle in here.

Verification

15 tests over real git repos and worktrees, including the autostash re-apply conflict, a file:// remote proving the fetch is load-bearing (a stale origin/main would otherwise falsely report up-to-date), and the slashed-local-branch case. cargo test (161) and tsc -b --noEmit green on this branch's upstream/main base. Also exercised live through the app: pull-without-upstream, up-to-date, base-moved merge, rebase conflict left resolvable, and autostash restore.

The Git tab can show and switch branches, but a task worktree goes stale as
its base moves and there was no way to catch it up from inside the app. This
finishes simion#101's update/pull/rebase.

Adds task_git_update(mode = pull | merge | rebase):
  - pull  = the branch's own upstream. Hidden until the branch is pushed,
            since task branches are cut --no-track and have no upstream yet.
  - merge / rebase = fetch, then integrate the task's base_branch.

Mechanics worth reviewing:
  - Network git goes through the hardened fetch path (GIT_TERMINAL_PROMPT=0,
    batch SSH, 15s deadline), never a bare `git pull`. The plain git() helper
    has no timeout and would wedge a spawn_blocking thread on a credential
    prompt. I extracted that hardening out of git_fetch_base into fetch_ref;
    git_fetch_base is now a best-effort wrapper over it, so the task-create
    path behaves exactly as before.
  - --autostash rather than a hand-rolled stash push/pop: git holds the stash
    outside `stash list` and re-applies it exactly when the op concludes,
    including after a `rebase --continue`.
  - A compound git op can exit 0 and still leave conflicts. When the autostash
    RE-APPLY collides, git commits the merge, prints "Applying autostash
    resulted in conflicts", retains the stash, and exits 0 with UU markers.
    That is reported separately as stash_conflicted, or we would tell the user
    their work was restored while it sat in a stash they were never told about.
  - Classification is by repo state (MERGE_HEAD / rebase dir / diff-filter=U),
    not by matching git's stderr, which is locale-dependent.
  - Conflicts are left in progress rather than auto-aborted: every task has a
    terminal attached, so the user resolves in place and an auto-abort would
    throw away a resolvable merge.
  - Members have no base_branch of their own, so it is joined from the owning
    project's member entry by repo_path.

Guards: detached HEAD; a base equal to the branch (repo-root and adopted tasks
record their own branch as the base); a removed remote (staying silent there
would merge a stale local-tracking ref); a local branch whose name contains a
slash (not mistaken for <remote>/<ref>).

Deliberately out of scope: a rebased branch that was already pushed will hit a
non-fast-forward rejection on the next push. Nothing is silently overwritten,
and force-push handling felt like a separate decision.

15 tests over real repos and worktrees, including the autostash re-apply
conflict and a file:// remote proving the fetch is load-bearing.

@simion simion left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merging.

One edge worth a follow-up: an update fired while a merge or rebase is already in progress gets misreported. The new git merge fails with "you have not concluded your merge", but merge_or_rebase_in_progress sees the old MERGE_HEAD and returns conflicted: true, so the toast claims the update hit conflicts. The user resolves and commits the old merge believing they are updated, and they are still behind base. A pre-flight in-progress check that errors with "conclude it first" closes it.

Nits:

  • --no-edit on the merge: git() runs without a tty, but a global merge.edit=true forces an editor spawn that fails and aborts with a confusing error.
  • loadBranches uses Promise.all, so a transient task_git_update_info failure also drops the branch list and the menu shows "No local branches". allSettled would keep the switcher usable.
  • Tests: the autostash re-apply conflict is covered for merge but not rebase, and pull has no happy-path test.

On the question in the description: keeping conflicts in progress is right. Every task has a terminal attached, and auto-aborting would throw away a resolvable merge.

@simion
simion merged commit 0632869 into simion:main Jul 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants