Skip to content

v1.39.0

Latest

Choose a tag to compare

@VictorQuilgars VictorQuilgars released this 23 Sep 12:18

Security

  • API keys were stored unencrypted, and are not any more. Regenerate them. Credentials in settings.json go through the system's protected storage — the Keychain, DPAPI, the keyring — so a file copied to another machine yields nothing. Which settings are credentials was a list written by hand when there were four providers, and #169 then made adding a cloud a catalog line: nobody edited the second list. So Mistral, DeepSeek, xAI and OpenRouter have been writing their API key to settings.json in clear since v1.32.0, and handing it to the window unmasked. The list is now derived from the catalog itself, so a provider is sealed by arriving rather than by being remembered, and a test walks the catalog and fails on any key that is not a secret. Sealing had always happened on write, which was correct and not enough — nothing forces a write, which is how this lasted six releases — so the app now seals at startup, before the window can ask for anything. ⚠️ That fixes the file and only the file. A settings.json rewritten today says nothing about the copies a backup, a synced folder or a passing script took while it was readable, and the app can reach none of them: if you had a key on one of those four providers, regenerate it at the provider. The app says so itself — the bell carries one notification naming the providers it found in clear.

Added

  • A conflicting pull request says which files, and offers the way out. The forge answers would this merge with a boolean, and the request's pane rendered it and stopped: Conflicts with the base, one line, naming nothing and offering nothing — the one place in the app that said something was wrong and gave neither the what nor the how. The names were always one merge-tree away, and the branch card beside it already showed them for a branch. The pane now lists the conflicted paths, with the count on the row itself, for a request whose head is in a fork as much as one whose head is a branch here: both sides are read from the remote — a local branch of the base's name may be behind, and a prediction against a stale base is a prediction about nothing — and no ref is written, the two commits being read from FETCH_HEAD rather than fetched into a branch. Under the list, the two ways out, with what each costs: Update from base, which rewrites nothing, then Rebase onto base — offered on the checked-out head branch and nowhere else, since both rewrite the branch you are standing on, and a fork's head is not a branch of this repository at all. Either lands in the conflicts panel the app already has, Resolve all with AI included, and nothing is committed for you. A prediction that could not run — a git below 2.38, a head the remote will not give — reads as could not tell, never as no files. (#305)
  • A branch says which of its commits the target already has, under another hash. A branch merged on the forge with rebase or squash does not put its commits on the target: it puts copies there, same patch, new hash. A branch that was stacked on that one still carries the originals — and then every changelog-shaped file conflicts, over a change that is already in, twice. The branch's card now says so — 2 of these commits are already in main, under another hash, with each one's sha and subject — and offers the one step that fixes it, Rebase onto main to drop them. The answer is git cherry, which is patch-id underneath and the same test the rebase itself uses to drop them: what is named is exactly what the rebase would remove, not a guess from subjects or dates. (#307)
  • A worktree row carries its branch's menu. The row knew which branch the worktree held and offered nothing about it: syncing that branch, giving it an upstream, renaming it, comparing it or opening its pull request meant going back to Branches and finding it again. A right-click on a worktree now offers the branch's own menu, after the worktree's own entries — push, upstream, merge, rebase, rename, delete, compare, the pull request, graph display — built by the same builder the branch row uses, so an action sits in the same place whichever row was clicked. What the row above it already answers is left out: its Open is opening that worktree, and git refuses both a switch to a branch another worktree holds and a second worktree for one. A detached worktree is on no branch, and keeps its worktree entries alone. (#286)

Changed

  • What this repository keeps has a page, and the side bar has its room back. Keep filed a search or a comparison under Kept, a block between the working changes and the branches — a column 260 px wide, where a kept search can only ever be a name, and where an empty list still spent a heading and a sentence of explanation above LOCAL. A kept search is not a name: it is a question, and the commits that answered it. It is a page now — Memory, from the bookmark beside the bell, and an editor tab from the panel's toolbar: the kept things on the left, and on the right what the one you picked actually holds — the query as its operators, when it was kept, and its commits, each with its author and its date. From there: show them in the graph, put the search back in the field, compare the oldest with the newest, copy the hashes. Ask again is what makes keeping one worth anything: the same query put to git, over the whole history, where the kept answer was a photograph of a live filter over the page the graph happened to hold that day — the commits it did not have are marked new, and Keep this answer instead makes today's answer the one it holds. A commit the repository no longer has is counted, not missed: 1 kept commit is no longer in this repository is what a rebase since looks like. A search the model answered is asked of the model again, not of git. A kept comparison lists its files with the ones you had ticked as reviewed, so 3 of 12 files reviewed is on the page rather than inside the comparison. And the kept searches are listed under the search field itself, in the panel that opens when it takes the focus: a search one kept is a search one means to run again, and that is where searches are run.
  • The search field asks the model without a button to find first. Searching in words was behind a ✨ at the field's far right: you had to press it before typing, and nothing said it was there. Meanwhile the panel that opens the moment the field takes the focus showed operators and nothing else — so the field announced, on the first click and to everybody, that author: and file: were all it could do. The panel now opens on Ask in plain language and the operators come under it: the row echoes the words you have typed — Ask the model for "the commits that broke the build" — and Enter in the field sends them, which is a key that meant nothing here, the filter being live as it is typed. The placeholder says both. There is no mode to arm any more: the model's answer being on screen is a state, drawn as the field's iris ring and a mark beside the count rather than a switch somebody has to remember throwing, and one keystroke in the query leaves it — back to the live filter, which is the feedback typing wants. A query made of operators alone sends nothing: that filter already holds. The ✨ goes with it: the icon set has carried an ai mark for as long as the app has had AI in it, and the search field was the one place a glyph stood in for it. The VS Code panel has no model behind its field and gets no such row, rather than a row that answers not-implemented.

Fixed

  • A search stays with the repository it was typed in. Everything else about a repository came back with its tab — its page, its selection, how far it had been loaded — but the search field was state of the window: switching to another repository left the words typed in the one you had just left sitting in the field, the new repository's graph opened greyed out underneath them, and the count read 0 about commits nobody had searched for. Worse in the two cases that are not text at all: the model's answer and the show these commits set of an AI reading both kept lighting rows in a repository they had nothing to say about. The search — the query, the diff-search toggle, the model's answer and the noted set — is kept per repository now and comes back with its tab, which is what the tab already promises about everything else; a repository whose tab is closed is forgotten with it. The VS Code panel has cleared the search on a switch since it learnt to show more than one repository, and keeps doing exactly that: it has no tabs to come back to.
  • The graph no longer draws commits it cannot name. A page was collected with --all, which is every ref under refs/, while a chip can only ever say branch, remote branch, tag or stash — which is exactly the set git's own decoration covers. So for a ref outside them %D came back empty, and its commits became rows belonging to nothing: absent from the side bar, unhideable (visibility is stated in those same four families), and refused by every action, all of which are defined against the branch you are on. Drop answered Commit not found about a commit whose hash was on the row that had just been right-clicked. git filter-branch leaves such a ref in refs/original/, and a branch squash-merged on the forge then kept its originals in the graph for good; an editor's refs/prefetch/, a refs/notes/ commit and a bisect's refs/bisect/ did the same. The searches were asked with the same --all and are asked with the same families now, so a file: hit, a diff search hit and a name in the contributors list are all things the graph has rows for. The page is collected from the four families now, and git still decides reachability inside each — hiding is still tips taken away, not a list of the survivors. The one thing --all gave that a collector does not is that it examines every working tree, so the HEAD of each other one is added back: a worktree's side bar row goes to its HEAD, and a detached one would have sent the graph somewhere it had no row for. That list costs a process, so it is only asked for when there are linked working trees at all — a question the disk answers in microseconds, and the answer is no for most repositories.
  • A rewrite says which branch, instead of saying the commit does not exist. Drop, Move and the rest are one rebase of the checked-out branch, so the commits they can act on are the ones that branch's history holds — and the graph draws every branch, so asking this of a commit on another one is an ordinary mistake to make. Two different states wore one sentence, and the wrong one: Commit not found, about a commit with its hash and its subject on screen. It now reads 4641c8d is not on "main" — rewriting can only remove commits from the branch you are on, and No such commit is said only about a commit git confirms there is none of, which is asked on the failure path alone.
  • A file tree no longer starts against the panel's edge. The depth of a row was written straight into its left padding, which replaces the row's own gutter rather than adding to it. Every level below the first had an indent wide enough to stand in for the missing gutter, so only the top one showed it: a top-level folder's disclosure triangle sat at 0, hard against the panel's edge, under a filter field inset from it — while the flat reading of the very same files, one click away on the same bar, started a gutter in, so the toggle shifted every row sideways. The indent is added to the gutter now, and the gutter is the left padding of the list that tree replaces, in the same spacing token — so the two readings line up and both follow the density. All three trees carried it, in both products: the commit's files, the working changes in the right panel, and the panel's own staging list.
  • The search field's panel no longer runs off the right of the window. It is as wide as what is in it, up to a cap, and it hung from the field's left edge unless it would overflow — measured once, as it opened. On an empty field it is narrow and fits; the moment something long went into it, it grew past the window and took the operators' examples with it, off the screen. It is measured after every change now, and still flips to the field's right edge once and once only, which is what keeps it from oscillating between the two sides. Both products carried it; the panel's field, at the right end of a narrow toolbar, was already at that edge.
  • The upstream picker no longer offers the remote itself. origin/HEAD is a symbolic ref to the remote's default branch, and the list of remote branches meant to drop it — but it filtered on a name ending in /HEAD, and %(refname:short) prints refs/remotes/origin/HEAD as plain origin. So the remote's own name sat among the branches, and picking it set an upstream to a moving target. It is dropped by being SYMBOLIC now, whatever it is called. The test that guarded this refused origin/HEAD too, and passed all along.
  • A branch that tracks a branch of another name is not published, and the card no longer says it is. git checkout -b feature origin/main points feature at origin/main — git does that on its own, from whatever the branch was created from — and everything that asked only has it got an upstream then called a branch nobody had ever pushed published. Its card read 3↑ and 3 to push, which was its distance from main; its Push button was refused by git outright, because the upstream's name is not the branch's and git will not guess which of the two was meant; and the pencil beside the name tried origin/feature and answered with eight lines of hint. The card now says what it tracks, keeps the distance — it is true, it is simply not "to push", so it wears neither colour — and offers Publish, the branch's own push -u, with Fetch. A bare push whose upstream is named differently is no longer attempted: the desktop opens the push dialog, where the remote and the target branch are chosen, and the panel asks once and publishes under the branch's own name. The pencil opens the picker the side bar's Change Upstream… has had since #280, with the remote branches listed and the one it already tracks to start from — it used to run setUpstream(branch) with no target, which can only ever set <remote>/<same name>, so on an unpublished branch the universal sign for edit this could only fail. And setting an upstream that is not on the remote answers in one sentence that names what fixes it — origin/feature is not on the remote — publish feature to create it. The card's heading says Unpublished rather than naming the branch it happens to track: Upstream origin/main, in the place where a branch's own remote name goes, reads as this branch's remote is main — which is exactly the thing to be afraid of, and frightening somebody out of pushing is a worse failure than saying less. What it tracks is said under it, where an explanation belongs. The side bar's rows had been asking the right question all along (publishedAs, "is there a remote branch of this name"), so the two surfaces had been disagreeing about the same branch in the same window. (#308)
  • A pull request says when it is not about what you have. A request is about the head that was pushed; the branch card, the graph and everything else on screen are about the branch here. Neither said which, so the two could contradict each other in the same window with nothing to explain it — an upstream card reading 2↓ 2↑ Diverged, a merge card reading Based on main with 1 new commit of a branch just rebased, and the request reading Conflicts with the base of the commit still on the remote. Three facts, all true, about two different commits. When the head branch's local tip is not the request's, the pane now says this request is about origin/<branch>, not what you have here, with how far apart they are and the Push or Pull that closes the gap — whichever there is something to do in. A head branch that exists nowhere locally says nothing new: there is nothing to disagree with. (#306)
  • A conflict prediction that could not run no longer reads as a clean merge. git merge-tree exits 1 both for these conflict and for I could not merge those at all — an unknown ref, a commit that is not one — and the second prints a sentence where the merged tree's id should be. Read as a list of conflicted paths it yields none, and none was taken for nothing will conflict: a typo in a ref name answered that the merge was clean, in every pre-flight warning the app makes. The answer's tree id is checked now, and an answer without one is a failure with git's own words. Both products carried that for as long as they had the code.
  • A reference's card closes when the reference is deleted. Deleting a branch from the card's own Delete Branch — either end of it, or both — left the selection exactly where it was, and nothing else closed the card: it stayed on screen with every button live, offering Switch, Merge, Rebase and Delete again on a branch git no longer had. A card now closes as soon as the reference it names is gone from the repository's refs, whoever deleted it and from wherever. Deleting only the remote end still leaves the local branch's card open, because there is still a branch to be about — it reads Unpublished from then on.