DeepSeek Harness Plugin

hunan36/dsh-git-flow

Stars ★ 3 Category Git & Code Review Added 2026-09-24

Branch chip in the composer tool row that switches, creates, and refreshes workspace branches, commits a checked subset of changed files with a message the session's own model writes from the diff, and pushes the current branch.

Install

# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)

dsh plugin --profile web add github:hunan36/dsh-git-flow

Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).

README

English | 中文

A DeepSeek Harness workspace git plugin: a branch chip in the composer tool row. Open it to browse branches, switch branches, create one, merge one into the current branch, and commit a checked subset of files — the commit message is written by the session's own model from the diff.

composer tool row:  [⑂ feature/login  3]  ← click (a Feature badge sits beside it)
                      ├ Search branches
                      ├ Local branches · 2   ✓ feature/login
                      │                        main
                      │                        ↳ right-click: Checkout · Merge · Rebase · Fetch
                      │                          Push · Rename · Delete · Copy name
                      ├ New branch…
                      ├ Commit…                (enabled while the tree is dirty)
                      ├ Refresh status
                      ├ Start git flow branch…
                      └ Finish git flow branch…  (on a flow branch, with a clean tree)

Install

Two ways in; either one is enough.

From the plugin page (GUI)

Sidebar → Plugins → Add plugin, then put this in "Package or address":

@hunan36/dsh-git-flow

Press Install and enable it afterwards ("Enable"). That path runs pnpm add in the profile serving the page (usually web) and writes this package into dsh.profile.bundles for you — you do neither by hand.

The GitHub address https://github.com/hunan36/dsh-git-flow also works, but pnpm pins the dependency to that commit and will not follow new releases. Installing by package name is what makes version comparison and updates possible.

Restart dsh web afterwards: host-half changes are not hot-reloaded, and the browser fetches the new client bundle on restart.

Updating

DSH does not auto-update third-party plugins; updating takes one command:

pnpm --dir ~/.dsh/profiles/web add @hunan36/dsh-git-flow@latest

Then restart dsh web. Host-half changes (services and API routes) only apply after a restart; the browser half refetches its bundle then.

Check the installed and latest versions:

npm view @hunan36/dsh-git-flow version

Installs by GitHub address are not followed automatically (pnpm pinned a commit), so reinstall explicitly to update:

pnpm --dir ~/.dsh/profiles/web add github:hunan36/dsh-git-flow#main

From the CLI (a dedicated profile is recommended)

# 1. Create a dedicated profile (leaves your existing web profile alone)
dsh --profile gitflow-web --from-default-profile web

# 2. Add the plugin — the npm package, a git URL, or a local checkout path
dsh plugin --profile gitflow-web add @hunan36/dsh-git-flow
# dsh plugin --profile gitflow-web add https://github.com/hunan36/dsh-git-flow
# dsh plugin --profile gitflow-web add "$PWD"

# 3. Enable it on the plugin page ("Enable"), or edit the bundle list directly:
#    dsh.profile.bundles += "@hunan36/dsh-git-flow"
#    The package declares dsh.bundle.patch, so enabling it mounts the GitFlow
#    service from the bundle's own cordis.patch.yml.

# 4. Boot
dsh --profile gitflow-web

The headless profile installs too, but it has no webServer: no /api/dsh-git-flow/* route is registered, the browser half does not exist there, and boot reports no error.

A machine without pnpm

dsh's plugin management — the plugin page's Install, and dsh plugin … — shells out to pnpm, and fails outright when there is none:

dsh: pnpm was not found; install pnpm and make it available on PATH.

Install one (Node ships npm):

npm install -g pnpm        # the latest 10.x
# or: corepack enable && corepack prepare pnpm@10 --activate

Do not let Corepack use its own default: after a bare corepack enable you get pnpm 8.15.7, which walks straight into the ERR_PNPM_ADDING_TO_ROOT failure below.

If you would rather not install pnpm at all, the plugin itself does not need it (its dependencies are empty):

  • Use Node's npm: cd ~/.dsh/profiles/<profile> && npm install https://github.com/hunan36/dsh-git-flow
  • Copy the directory: drop the whole dsh-git-flow folder (including lib/) into <profile>/node_modules/, for offline or fully manual setups

Both need one extra step: add @hunan36/dsh-git-flow to that profile's dsh.profile.bundles in its package.json, then restart dsh web. The plugin page and dsh plugin add do that for you; a manual install does not, and the package stays unloaded even though it sits in node_modules.

Point the plugin page at npm (a machine that only has npm)

The plugin page runs pnpm add <spec> by default, but the plugin manager's command is configurable — set it to npm in the profile's cordis.patch.yml and the page installs with npm:

# ~/.dsh/profiles/<profile>/cordis.patch.yml
- id: plugin-manager
  config:
    pnpmCommand: npm

Then use the plugin page exactly as usual (paste the address → Install → Enable). The CLI's dsh plugin … add does not honour this setting and still needs pnpm.

Three caveats:

  • This is an internal dsh setting and may move between versions; prefer installing pnpm when the machine allows it.
  • The arguments dsh passes are add <spec>, remove <name>, and view <spec> … --json, all of which npm accepts; but pnpm-specific semantics (-w, onlyBuiltDependencies, lockfiles) do not exist under npm, so do not mix both managers in one profile.
  • The command line shown in the UI and logs still reads pnpm add … (dsh builds that label literally). To tell which manager really ran, look for package-lock.json versus pnpm-lock.yaml in the profile directory.

Installation fails with ERR_PNPM_ADDING_TO_ROOT

A dsh profile directory is itself a pnpm workspace (pnpm-workspace.yaml with packages: [.]), and pnpm 8 treats pnpm add there as adding to a workspace root — which it refuses. When the profile has no packageManager field, Node's Corepack fills one in (commonly pnpm@8.15.7), and the install then dies on that check before it ever resolves this plugin, so the failure is not about this package.

Any one of these gets you through:

  1. Installing from the plugin page (GUI): that path runs pnpm add <spec> and cannot pass -w, so do step 2 first (write the .npmrc) and then press "Retry" in the dialog — the same command then succeeds under pnpm 8.

  2. Teach the profile to accept a root dependency, then install exactly as documented:

    echo 'ignore-workspace-root-check=true' >> ~/.dsh/profiles/gitflow-web/.npmrc
    
  3. From the CLI, pass -w through (dsh forwards extra arguments to pnpm):

    dsh plugin --profile gitflow-web add -w https://github.com/hunan36/dsh-git-flow.git
    
  4. Put the profile on pnpm 10 (the version this plugin is tested with): run corepack use pnpm@10 in the profile, or set the packageManager field Corepack added to pnpm@10.22.0.

Note that after the first failure Corepack has already written packageManager: pnpm@8.15.7+sha512… into that profile's package.json, so every later plugin operation there uses pnpm 8 — step 4 is the durable fix.

Requires Node ^22.19.0 || >=24.0.0 and pnpm 10; this repository pins it with packageManager: pnpm@10.22.0.

Configuration

Set in cordis.patch.yml:

Key Default Meaning
timeoutMs 15000 Deadline for read-only and local git commands
pushTimeoutMs 120000 Deadline for git push, which waits on a remote
messageMaxDiffBytes 65536 Cap on the diff bytes handed to the model (truncated past it)
messageLanguage en Default commit-message language, zh or en
productionBranch main Production branch name; set master for older repos
developBranch develop Integration branch name
featurePrefix feature/ Feature branch prefix
releasePrefix release/ Release branch prefix
hotfixPrefix hotfix/ Hotfix branch prefix
tagPrefix v Tag prefix applied when a release or hotfix finishes

The commit panel carries an English | 中文 switch beside "Draft message". It overrides messageLanguage for that call and remembers the choice in localStorage, so English stays the default until you pick otherwise.

Merging a branch

Right-click a branch row (or press the keyboard menu key / Shift+F10 while it is focused) to get the branch actions below. This section covers checkout and merge; rebase, fetch, push, rename, delete, and copy live in Branch actions.

  • Checkout — the same switch as a left click, including the dirty-worktree confirmation.
  • Merge into the current branch — runs git merge --no-edit <branch> on the current branch; nothing is pushed.

A merge that can fast-forward does; a diverged branch produces a normal merge commit with git's default message. The toast reports which happened. A remote-only row merges its origin/… ref without creating a local branch.

A conflict is not aborted: the conflict markers, the index and MERGE_HEAD stay in the worktree, and the chip starts showing the conflicted files. Resolve them and commit the merge yourself — from a terminal or by asking the session. The commit panel only makes partial commits (git commit --only), which git refuses while a merge is in progress. If local edits would be overwritten, the merge fails with git/dirty-worktree and the worktree is left untouched. Merging the current branch into itself and merging into a detached HEAD are both refused.

Branch actions

The same right-click menu covers the rest of SourceTree's local-branch actions. They act on the selected branch (except rebase, which always replays the current branch onto the selected one):

Action What it runs
Rebase current changes onto <branch> git rebase <branch> — the current branch's commits are replayed on top of it, so its history is rewritten
Fetch <branch> git fetch <remote> for the branch's remote; a remote-only row fetches its own remote
Push to <upstream> (tracked) git push <remote> <branch>:<tracked branch> for that branch
Push to… pick a remote and a destination branch: git push --set-upstream <remote> <branch>:<destination>
Rename… git branch -m for a local branch (the upstream config moves with it)
Delete <branch> git branch -d; a not-fully-merged branch asks for confirmation and retries with -D
Copy branch name copies the name to the clipboard

Remote-only rows offer checkout, merge, rebase and fetch (they merge/fetch their origin/… ref), plus copy; rename, delete and push are disabled because there is no local branch. The checked-out branch cannot be deleted, checked out again, merged into itself, or rebased onto itself.

A rebase conflict stops where git stopped: the rebase stays in progress, the chip shows the conflicted files, and the user finishes it with git rebase --continue (or git rebase --abort) from a terminal — the plugin never aborts or force-continues. Fetch and push are the only network actions in this menu, and both run only when clicked.

Git flow

The bottom two rows of the branch menu start and finish standard git-flow branches: features and releases branch off develop, hotfixes branch off production, and a finish merges back with --no-ff.

Type Branches off Merges into Tag + version bump
feature/ develop develop no
release/ develop production + develop yes
hotfix/ production production + develop yes

A finish runs: merge, tag, commit the package.json version, push the branches and tag, delete the local branch.

  • When develop does not exist yet, "Start" offers to create it from production (or the current branch).
  • Merges, tags, and version commits are not rolled back; a failed push reports "finished locally, but the push failed" and keeps the local result.
  • Everything that can be refused is checked before the first write: a dirty worktree, an existing tag, a missing target branch.
  • A merge conflict is left in place for you to resolve; nothing is aborted on your behalf.
  • A repository without a root package.json skips the version step instead of failing.

Security boundaries

  • The browser sends only a sessionId. The host resolves the repository from that session's workspace, so a page can never name a directory for git to run in.
  • Arguments are always an array, never a shell string, with stdin: 'ignore' and GIT_TERMINAL_PROMPT=0, so a credential prompt can never hang a call.
  • push never passes --force or --force-with-lease, and only pushes the current branch's upstream (with --set-upstream <remote> HEAD:refs/heads/<branch> when it has none).
  • Commits stage only the files checked in the panel. An empty selection answers git/no-files-selected and never falls back to an implicit git add -A.
  • A merge runs only git merge --no-edit <name> against the current branch, with a branch name the browser sent. It never passes --no-ff/--force/-X, never pushes, and never aborts a conflicted merge.
  • Branch actions run exactly one fixed git subcommand each: git rebase <branch>, git fetch <remote>, git push <remote> <branch>:refs/heads/<dest>, git branch -m <from> <to>, git branch -d|-D <branch>. Remote names are only accepted when they appear in git remote; fetch never prunes or resets; the checked-out branch can never be deleted; a -D force delete is only sent after the unmerged-branch warning and a second confirmation.
  • Switching branches on a dirty worktree fails by default (git/dirty-worktree). Only the user's second confirmation in the dialog sends --force.
  • Discarding runs only after the destructive action is picked in a confirmation dialog: tracked paths go back to HEAD with git restore --source=HEAD --staged --worktree, untracked paths are removed with git clean -fd (no -x, so ignored files stay), and a conflicted path is refused rather than guessed at.
  • workspaceRegistry is read lazily through ctx.get('workspaceRegistry') instead of being declared in static inject, so a profile without one (the shipped headless profile) still activates this entry rather than leaving a permanently pending plugin; every git call there answers git/not-a-repository.
  • A missing git, a non-repository workspace, a timeout, and every refusal travel as structured codes (git/not-installed, git/not-a-repository, git/timeout, …) that the UI renders as copy. Outside a repository — or without git — the chip renders nothing at all.

Layout

src/
├── index.ts       host entry (default-exports the GitFlow service)
├── service.ts     GitFlow: status / branches / checkout / createBranch / merge / rebase / fetch / push / pushBranch /
│                  renameBranch / deleteBranch / remotes / commit / discard / pull / generateMessage
│                  + gitflowState / startFlow / initDevelop / finishFlow
├── git.ts         GitRunner: subprocess + scrubbed env + timeout + porcelain v2 parsing
├── routes.ts      /api/dsh-git-flow/* (registered only where a webServer exists)
├── message.ts     commit message: ctx.llm.stream on the session's own provider/model, template fallback
├── contract.ts    host↔browser types only
└── client/
    ├── index.ts        slots.inject('conversation.input.left') + locale registration
    ├── BranchChip.tsx  container: status, menu, dialogs, toast
    ├── BranchMenu.tsx  the chip and its anchored menu
    ├── BranchActionsDialog.tsx rebase confirmation / rename / delete (force) / push-to-remote dialogs
    ├── CommitDialog.tsx commit panel / new branch / forced-switch and discard confirmations
    ├── GitFlowDialog.tsx start a flow branch / guided develop creation / finish a flow branch
    ├── api.ts          fetch wrapper + error codes to copy
    ├── styles.ts       the few CSS rules the popover width and file-row ellipsis need
    └── locales.ts      zh/en dictionaries (en is typed against zh, so a missing key is a compile error)

Build

pnpm install
pnpm build       # tsc -> lib/types/**, tsdown -> lib/index.js + lib/client.js

lib/ is committed to the repository (it is not in .gitignore) and the package no longer carries a prepare script, so installing from the git URL needs no build, no dev dependencies, and no entry in pnpm's onlyBuiltDependencies allowlist:

  • pnpm 10.34+ blocks a git dependency's prepare (ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED), and the allowlist entry it demands is dsh-git-flow@<spec>#<commit sha> — a string that goes stale on every upstream commit;
  • shipping the build instead makes dsh plugin add <spec> take lib/ as-is: measured at 263 ms under pnpm 10.34.5.

After editing src/, run pnpm build and commit lib/ along with it, or everyone installing from git gets the previous build.

Artifact contract:

  • lib/index.js: Node ESM, imported directly by the cordis loader.
  • lib/client.js: a CJS factory wrapped as window.__ModuleLoader__.load({ id: "@hunan36/dsh-git-flow", factory: (require) => … }) (the id is package.json's name); react, react/jsx-runtime, and @deepseek-ai/dsh-client-ui-primitives stay external and come from the page's static module table.

Compatibility

Developed and verified against @deepseek-ai/dsh@0.1.6-alpha.2, with peer and dev dependencies pinned to it. dsh is still alpha, so contracts such as conversation.input.left, ctx.webServer.register, and ctx.slots.inject may drift between minor versions. After upgrading dsh, rebuild and confirm the first line of lib/client.js is still window.__ModuleLoader__.load({ id: "@hunan36/dsh-git-flow".

Known limits

  • An AI commit message needs one turn already sent in the session (the provider/model recorded in request/header). Without a route the panel answers git/no-route and tells you to send one message first; when the model fails or returns nothing usable, the chore: update N files template fills in and says so.
  • Messages default to English. The panel's language switch (remembered per browser) and the messageLanguage config are the only two inputs; the template fallback follows the same choice.
  • Remote branches appear only once refs/remotes/** exists locally (the plugin never fetches in the background); "Refresh status" makes no network call.
  • Conflicted files cannot be checked; resolve the conflict in the conversation first.
  • git switch --force discards all local changes, not just the conflicting ones — the confirmation copy says exactly that. To drop a single file's changes instead, use that file's Discard action in the commit panel.
  • Switching to a ref that exists nowhere fails inside git and is reported as git/failed with git's own text. Only stale UI state can reach that path, since the menu lists branches that exist.

Verification (0.1.6-alpha.2)

Measured on an isolated DSH_HOME with profile gitflow-web (@hunan36/dsh-git-flow appended to dsh.profile.bundles): the chip text equals git branch --show-current; the menu lists every local branch plus each remote-only one; search filters rows; switching moves both the chip and the worktree files; a dirty switch is refused and only confirms --force after a second dialog; an invalid branch name errors while a valid one is created and switched to; with a single file checked, the model returned a feat: … message and git log -1 --name-only showed only that file; commit-and-push made the branch appear in git ls-remote with its upstream set; a session whose workspace is not a repository renders no chip; the headless profile boots with no warning and no routes.

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →

Community comments

Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.