Skip to the content.

How Git is Used in DGS

A conceptual overview of how DGS manages git operations — work modes, worktrees, merging, and setup. For command reference, see the User Guide.


The Three Work Modes

DGS provides three modes for making changes. Each mode manages git differently based on the scope of work.

Mode When to Use Creates Worktree? Branch Merges Via
Fast (dgs:fast) Trivial 1-10 line fixes: typos, config tweaks No Direct to base_branch Immediate commit
Quick (dgs:quick) Bug fixes, small contained changes Product-level: yes. Milestone-context: no quick/{title} or milestone branch dgs:complete-quick (rebase + merge)
Milestone (execute-phase) Planned multi-phase work Yes (on first execute-phase) milestone/{slug} dgs:complete-milestone (rebase + merge)

When git.completion_mode: pr is set, the “Merges Via” completion commands open a GitHub pull request instead of merging locally — see Completion Modes: Merge vs PR.

Decision Flow

Two Flavors of Quick

Product-level quick (no active milestone, or --main flag):

Milestone-context quick (active milestone, no --main):

The --full and --debug flags change workflow guidance (tests expected, investigation focus) but git mechanics are identical across all flavors.

The Base Model: Inline vs --main

Every piece of work has a base — the branch its changes ride on. DGS resolves it with one rule: inline by default.

The --main mentions elsewhere (Command Reference, Milestone Jobs Guide) all refer to this model. How the completion of that work then lands — local merge or pull request — is a separate, orthogonal switch: Completion Modes: Merge vs PR.

Selecting the Base: --from <branch>

By default a product-level quick is cut from git.base_branch (normally main). Pass --from <branch> to cut it from a different branch instead — useful for stacking a fix on top of a release branch or another in-flight line of work.

Note: --from main (or --from naming whatever git.base_branch resolves to) behaves like a plain product-level quick — same base, no lock.


Worktree Lifecycle

A git worktree is a second checkout of the same repository in a different directory. DGS uses worktrees so the main checkout stays clean and available for fast fixes while longer-running work happens elsewhere.

Directory Layout

~/dev/
├── myapp/                          <- main checkout (always on main, always clean)
│   └── ...
├── myapp--gsd-v19/                 <- milestone worktree (on milestone/v19 branch)
│   └── ...
└── myapp--gsd-quick-fix-auth/      <- quick worktree (on quick/fix-auth branch)
    └── ...

Worktrees are siblings to the main checkout. The naming convention is {repo}--{project_slug}-{milestone_or_quick_slug}.

Lifecycle Stages

  1. Createdexecute-phase (milestone) or dgs:quick (product-level) creates the worktree automatically on first use.
  2. Active — Work happens in the worktree. Commits go to the worktree’s branch. The main checkout is untouched.
  3. Completedcomplete-milestone or complete-quick rebases, merges to main, removes the worktree and branch.

For milestones, the worktree persists across all phases. It is created on the first execute-phase and removed by complete-milestone.

For product-level quicks, the worktree is ephemeral. Created by dgs:quick, removed by complete-quick or abandon-quick.

Up to 3 standalone quicks can be live at once, each in its own worktree. See Parallel Consoles & Focus for how each console keeps its own focus among them.


Rebase-Before-Merge Strategy

DGS uses rebase-before-merge for all completion workflows. This produces a clean linear history with no merge commits.

Step-by-Step Flow

Both complete-quick and complete-milestone follow the same sequence:

1. Pull latest main          git fetch origin && git pull origin main
2. Rebase in worktree        git -C {worktree} rebase main
3. If conflicts              → conflict-agent attempts auto-resolution
                             → if it can't: abort rebase, show manual instructions
4. Fast-forward merge        git merge --ff-only {branch}
5. Push                      git push origin main
6. Cleanup                   remove branch + worktree

What the History Looks Like

Before rebase:
main:      A---B---C
                \
milestone:       D---E---F

After rebase + ff-merge:
main:      A---B---C---D'---E'---F'

The result is a single straight line. No merge commits, no tangled history.

Conflict Handling

When rebase encounters conflicts:

  1. DGS’s conflict-agent tries to resolve automatically, processing each commit during the rebase one at a time.
  2. If it cannot resolve: the entire rebase is aborted (git rebase --abort), leaving the worktree in a clean pre-rebase state.
  3. DGS provides copy-paste commands for manual resolution:
cd ~/dev/myapp--gsd-v19          # cd to worktree
git rebase main                  # start rebase
# resolve conflicts, then:
git add .
git rebase --continue
# repeat if multiple commits have conflicts
  1. After manual resolution, re-run complete-milestone or complete-quick. It detects the rebase is already done and skips straight to the fast-forward merge.

Note: During rebase, “ours” refers to the working branch and “theirs” refers to main. This is the opposite of the merge perspective.

Automatic Conflict Hygiene: rerere + zdiff3

DGS automatically sets three git config values in every DGS-managed repo:

Key Value What it does
rerere.enabled true Records how you resolved each conflict so git can replay it if the same conflict recurs
rerere.autoupdate true Stages a replayed resolution automatically, without an extra git add
merge.conflictStyle zdiff3 Adds a merge-base section (\|\|\|\|\|\|\|) inside conflict markers, alongside the usual “ours”/”theirs” sections

When it’s applied. These settings are written at two moments: when a repo is registered (/dgs:add-repo), right after the .git/ validation, and on every worktree creation, right after a successful git worktree add. Repo-local git config is shared by a repo’s main checkout and all of its worktrees, so a single application at registration time already covers every worktree cut from that repo later.

Scope: repo-local, never global. DGS writes these with a plain git config <key> <value> — it never passes --global or --system. Your global git config is never touched; this is a locked design constraint, not an oversight.

Warn-only. If setting any of the three keys fails, DGS prints a warning to stderr and continues — it never aborts repo registration or worktree creation over a config nicety.

Why it matters. Rebase-before-merge (DGS’s default completion strategy) replays your commits onto the target branch one at a time, so the same conflict can recur across several commits in the same rebase; rerere remembers the resolution and replays it automatically instead of asking you to redo it. zdiff3’s merge-base section gives both you and DGS’s conflict-agent three-way evidence — what the file looked like before either side changed it — which is what the classification logic in Conflict Handling above relies on to distinguish a real conflict from two sides doing the same thing.

See Configuration Reference for how to inspect or opt out of these settings.


Completion Modes: Merge vs PR

Both complete-quick and complete-milestone honor one config key: git.completion_mode.

Value What completion does
merge (default — an absent key means merge) The rebase-before-merge flow above: rebase, fast-forward merge to base_branch, push, remove worktree and branch
pr Rebase, push the branch, open (or update) a GitHub pull request — then stop. Nothing merges locally; the merge happens on GitHub

Set it in the tracked config.json:

dgs-tools config set git.completion_mode pr

The value is enum-validated — anything other than merge or pr is rejected.

Selecting the Target: --onto <branch>

Where --from chooses the base a quick is cut from, --onto <branch> chooses the target it completes onto — the branch the quick’s own commits are rebased onto and then merged (or PR’d) into. The default target is git.base_branch (normally main).

Note: With no --from and no --onto, base and target are both git.base_branch/main and behavior is exactly as before v25.4 — byte-for-byte, in both completion modes. The transparency line → completing onto '<branch>' is printed only for a non-default target; the plain default path stays silent.

Entry-State Lock Lifecycle

--from and --onto meet on the worktree entry, through three fields — create_base, locked, and target_branch — stamped at create and enforced at completion:

The PR Flow (completion_mode: pr)

PR completion forks off the same rebase prefix the merge path uses, then:

  1. gh preflight — verifies the remote is a GitHub host and the GitHub CLI (gh) is installed and authenticated, before anything is pushed.
  2. Leased push — the work branch (quick/{slug} or milestone/{slug}) is pushed with git push --force-with-lease (never plain --force), so a rebase-rewritten branch updates its remote without being able to clobber unseen remote work.
  3. Idempotent PR open/update — guarded by gh pr list --head: the first run creates the PR (gh pr create, seeding a title and a commit-list body); re-runs push the new head only and never overwrite a human-edited PR title or body.
  4. Stop. No fast-forward merge, no push to base_branch, no teardown. The worktree and branch stay until the PR merges.

DGS records what it opened on the worktree entry — per repo: pr_number, pr_url, and pr_head_sha (the exact head that was pushed) — and flips the entry to state: pr_open.

gh is required only in PR mode (and for fast-PR). The default merge path never invokes the GitHub CLI. If the preflight fails, the error says how to install or authenticate gh — or to set git.completion_mode back to merge.

The Open → Reap State Machine

A pr_open quick or milestone is parked, not finished. Re-running its completion command drives the state machine:

Milestone-specific: four-eyes governance gates at PR open only; the post-merge reap re-run needs no re-approval.

Multi-Repo PR Tracking (entry.prs)

A multi-repo quick or milestone opens one PR per touched repo, and each repo keeps its own record on the worktree entry’s entry.prs map:

"prs": {
  "api-service": { "pr_number": 41, "pr_url": "https://github.com/...", "pr_head_sha": "..." },
  "web-app":     { "pr_number": 17, "pr_url": "https://github.com/...", "pr_head_sha": "..." }
}

Parallel Consoles & Focus

Parallel work is per-console: each console (Claude Code session or raw shell) resolves its own active context — the milestone or standalone quick its commands operate on — with this precedence, highest first:

  1. --context <slug> — a one-shot per-command flag
  2. Session binding — CLAUDE_CODE_SESSION_IDconfig.local.json execution.console_bindings (the default path inside Claude Code; no shell setup)
  3. DGS_CONTEXT — the per-shell environment variable (raw-shell path)
  4. The config default (execution.active_context)
  5. None (product / main)

Two consoles on the same project can therefore each drive a different milestone or quick: commit routing, completion, and the status bar all follow the console’s own focus. The Concurrency & Locking layer below is what makes this safe — concurrent writes to the shared config store and planning repo are serialized and never lost. For setup and day-to-day use (binding a console, one-shot overrides, checking a binding), see Per-Console Context in the User Guide.


Concurrency & Locking

Parallel work is normal in DGS — multiple Claude Code consoles, parallel quick tasks, multi-repo milestones. All of it shares two pieces of state in the planning repo: config.local.json (worktree entries, focus/context bindings, execution locks, PR records) and the planning repo’s git index (STATE.md, roadmap, plan artifacts). Since v25.2, a cross-process locking layer serializes writers of both, so concurrent commands cannot corrupt shared state. This section describes that layer at the level you can observe and rely on, and how to recover a stuck run.

The Lock Substrate

The substrate is one primitive (in config-lock.cjs): an O_EXCL sentinel file — .dgs-lock-<key>.sentinel, created next to config.local.json — keyed by mutex name. Creating the file is atomic (O_CREAT | O_EXCL), so exactly one process can hold a given key at a time; the sentinel is always removed when the critical section ends. No external locking dependencies are involved.

There are two mutex keys, and they are never nested:

Key Guards Typical hold
__config__ Every read-modify-write of config.local.json Sub-millisecond
__planning_git__ The planning repo’s git add + git commit (and STATE.md read-modify-write) critical sections Commit-duration (seconds)

Config writes go through the guarded mutate(cwd, '__config__', fn) primitive: the whole config file is read fresh under the lock, the caller mutates only its own subtree in place, and the whole freshly-read object is written back, still under the lock. This field-level merge means two processes writing different subtrees — say, one binding a console while another records a worktree entry — can never lost-update each other; the classic read-then-write window is closed. Git critical sections use withLock(cwd, '__planning_git__', fn), a pure mutual-exclusion section that never touches the config file (so a multi-second commit never blocks config writers).

Lock acquisition is bounded, never blocking-forever: a single attempt retries the sentinel for roughly half a second, and callers that need to ride out momentary contention retry the whole operation up to 6 times before failing loudly with a lock-contention error stating that the write did not land. A caller never hangs indefinitely and never proceeds unlocked.

Stale-reclaim (per key). A sentinel leaked by a crashed holder would otherwise wedge its key forever, so each key has its own stale-reclaim threshold: a __config__ sentinel older than 5 seconds is reclaimed (those writes are sub-millisecond, so anything older was leaked), while a __planning_git__ sentinel gets 60 seconds — a legitimately slow multi-second git commit is never mistaken for a leak and never reclaimed out from under itself. These are deliberately fixed thresholds rather than a sentinel heartbeat: a crash self-heals after the window, with no moving parts. (A heartbeat does exist one layer up, on the longer-lived execution lock — see below.)

What Each Mutex Covers

The config store. Every DGS writer of config.local.json — worktree entries, active-context and console bindings, execution locks, fast-PR records, per-repo PR tracking — routes its read-modify-write through the guarded mutator under __config__. No unlocked writer of that file remains.

The planning-repo git mutex. Every DGS committer to the planning repo — the quick, milestone, and phase artifact committers, the STATE.md writers, and the generic commit command — stages and commits under __planning_git__, so two committers operating in the same single planning checkout can no longer interleave their git add / git commit steps.

Path-limited commits. Committers do not commit the whole index. Each commit is path-limited to exactly the pathspecs that committer just staged, via git commit -m <msg> -- <paths>. Even if a lock were somehow bypassed, one writer’s commit cannot sweep another writer’s staged-but-uncommitted files into the wrong commit.

git push happens outside the lock. The mutex covers only the local critical section (stage + commit). A network push is never performed while holding __planning_git__, and a push only runs after a commit has actually landed. Pushes are therefore not serialized by this layer — only local history writes are.

Guarantees You Can Rely On

These are behavioral guarantees proven by multi-process contention tests (below), not aspirations:

What the tests prove. Two fork-based harnesses back these claims with real multi-process races. concurrency-race.test.cjs proves the substrate primitives: 200 concurrent config writes across two processes lose zero entries, concurrent ID draws contain zero duplicates, exactly one milestone entry survives a creation race, concurrent planning-repo commits each contain only their own files, and the per-key stale thresholds hold (a live slow commit is not reclaimed; a leaked config sentinel is). concurrency-caller-race.test.cjs goes further and drives the real command callers — console binding, config-set, fast-PR record, the generic committer, milestone worktree creation, PR-record persistence — under two-process contention with no test-side retry wrapper, proving each caller surfaces contention as a failure (an explicit error or contended: true result) instead of false success or a silent drop.

The Planning-Tree Cleanliness Invariant

The locking substrate above stops two writers from corrupting shared state at the same instant. A second, higher-level guarantee governs what each session is allowed to leave behind and what it is allowed to block on: the planning-tree cleanliness invariant (milestone v31.1). Because every console shares ONE planning-repo working tree — one .git/index, one branch — a session that leaves stray changes behind, or that blocks on changes it does not own, breaks a neighbour just as surely as a lost update would.

Three definitions ground the invariant (copied verbatim from the milestone spec, INV-01):

The invariant has two parts:

Every gate, hook, ID-allocator, and lock in the engine references the single authoritative statement in references/planning-tree-cleanliness-invariant.md rather than re-deriving it — that single-source referencing is what makes the guarantee enforceable instead of a matter of review vigilance. The invariant is enforced in CI by a mutation-verified static-scan guard test (bin/lib/planning-commit-audit.test.cjs), blocking on merge by construction. The long-operation lock and the config-flag/recovery surface documented below are the concrete mechanisms that enforce this invariant; they extend the substrate above rather than replacing it.

Recovering a Stuck Run: the Execution Lock

Separate from the sub-second sentinel mutexes above, DGS keeps a longer-lived execution lock: one executor per milestone worktree at a time. The lock record is execution.executing.<slug> = { started_at, session_id } in config.local.json (its own reads and writes run under __config__, so acquiring it is race-free). If a second executor tries to start against the same worktree while the lock is live, it is refused and told which session holds it.

Two staleness layers — don’t confuse them:

Layer Threshold What staleness means
Sentinel stale-reclaim (__config__ / __planning_git__) 5s / 60s A leaked lock file from a crashed process; self-heals automatically
Execution-lock staleness 6 hours A crashed run; auto-released on the next acquire, or freed manually with release --force

Owner-Identified Long-Operation Lock (lock_long_ops)

The two locks above cover short critical sections — a sub-second config write, a seconds-long commit. A few planning operations run much longer (a multi-second, occasionally multi-minute rebase) and must not be stolen from a live holder mid-operation. For those, DGS ships a third, flagged lock layer: an owner-identified, heartbeat-aware long-operation lock, enabled by lock_long_ops (default OFF). With the flag off, behaviour is byte-for-byte the existing short lock — this layer adds nothing until you opt in. The lock file is .dgs-long-op-lock.json, written next to config.local.json and gitignored.

Lock-file schema (version is the schema version, currently 1):

Field Meaning
owner_session_id The session that holds the lock
owner_pid Holder’s process id — diagnostic only, never the liveness signal
owner_host Holder’s hostname
heartbeat_ts Timestamp the holder last stamped
op_type The operation being guarded (e.g. rebase)
version Lock schema version (currently 1)

Liveness — the heartbeat, not the PID. An owner is live iff now - heartbeat_ts <= HEARTBEAT_TTL (default 90s, lock.heartbeat_ttl_ms), and that heartbeat is read from the session heartbeat (session-state.cjs execution.session_states[sessionId].ts) — never decided by owner_pid or by the lock file’s own heartbeat_ts field. A PID can be reused or belong to an unrelated process, so it is diagnostic only, never the liveness test.

The heartbeat refresher (correctness-critical). Acquiring a long-op lock starts a background, detached child process that re-stamps the session heartbeat every min(HEARTBEAT_TTL/3, 30s) = 30s at the default TTL, and stops on release. It MUST be a separate OS process, not a same-process timer: a synchronous multi-second git op blocks the parent’s event loop, so an in-process timer could never fire to refresh the heartbeat mid-operation — which is exactly the window in which a live holder would otherwise look dead and be stolen during a long rebase.

The steal rule. Acquire-by-steal happens ONLY when the owner is NOT live AND the lock age exceeds STEAL_THRESHOLD (default 120s = HEARTBEAT_TTL + 30s, lock.steal_threshold_ms). A live owner is never stolen; a paused-but-live holder (a stall shorter than the TTL) survives. Both thresholds are config-overridable.

Short-commit vs long-op interaction. The preserved short-commit fast path (the ordinary planningCommit / commitInternal route) does a NON-blocking owner check (checkNoLiveLongOpConflict): with the flag OFF it is zero added I/O; with the flag ON and no lock present it is one cheap stat; and if a different live session owns a long-op lock, the short commit fails fast with held_by (reason long_op_held) rather than stealing or blocking. A crashed owner does NOT block a short commit — recovery there is the steal rule or dgs-tools lock clear --force, not a short commit.

The held_by error format (LOCK-02). A refusal carries a structured payload and a human string. The payload is:

held_by: { session_id, pid, host, last_heartbeat_age_s, op_type }

and the human string lets a person tell a live neighbour from a crashed holder at a glance:

This is a third staleness layer, longer and owner-identified, sitting above the two in the table just above: the sentinel stale-reclaim (5s / 60s) heals a leaked lock file, the execution-lock staleness (6h) heals a crashed run, and this layer keeps a genuinely-live long operation from being reclaimed — on the heartbeat’s own timescale (90s live / 120s steal).

Config Flags, Rollback & Recovery

The v31.1 cleanliness mechanisms each ship behind their own flag with a safe default, so any one can be turned off without touching the others — each is independently revertible (per-mechanism rollback).

Flag Default (as built) What it enables Roll back by
gates.console_scoped true (console-scoped gates ON) The G1/G2/G3/G5 cleanliness gates block only on YOUR own work, not a peer console’s dirt set gates.console_scoped false → restores the global whole-tree gate
lock_long_ops false (OFF) The owner-identified, heartbeat-aware long-operation lock above leave off (the default) → the existing short-lock fast path
hook_ownership_mode 'B' The safety-net hook commits only its declared path allowlist mode 'B' IS the safe default; 'A' (foreign-skip + counter) is the opt-in enhancement, gated behind a runtime console-binding deployment check

“Scoped ON by default” is still fail-closed. gates.console_scoped ships defaulting to true (scoped on / opt-OUT), not to the global gate. Two facts keep that safe. First, on unresolved ownership — no session id, kill-switch off, a git failure, or an internal error — the gate helper (isMyWorkClean, in work-clean.cjs) safe-degrades to a byte-identical global whole-tree gate (verdict degraded-global, fail-closed): if it cannot prove the dirt is not yours, it blocks on everything. Second, genuine repo-state hazards — an in-progress merge / rebase / cherry-pick, or a detached HEAD — abort unconditionally, scoped or not. So the scoped default narrows blocking to your own work only when ownership is provable, and falls back to blocking-on-everything whenever it is not.

Manual recovery for a poisoned long-op lock. If a long-op lock’s holder crashed — its heartbeat aged out but its .dgs-long-op-lock.json sentinel lingers — recover it explicitly:

Security (SEC-1). The session/owner fields (owner_session_id, owner_pid, owner_host) are local cooperative trust signals only — they are NOT an authorization boundary, and nothing gates access control on them. They exist to attribute and diagnose, not to authenticate.

Supported platforms. This layer assumes a local POSIX filesystem (macOS / Linux) where O_EXCL file creation is atomic. NFS and Windows O_EXCL semantics are out of scope for this milestone.


Setup Commands & Monorepos

REPOS.md has an optional setup field per repo. DGS runs this command whenever it creates a worktree, handling dependency installation and environment preparation automatically.

REPOS.md Setup Field

| Name | Path | Setup |
|------|------|-------|
| api-service | ../api-service | npm install |
| web-app | ../web-app | ./scripts/setup-worktree.sh |

The setup command receives:

Simple Node.js Project

setup: npm install

npm/pnpm Monorepo

#!/bin/bash
# scripts/setup-worktree.sh
SLUG=$1
WORKTREE_PATH=$2

# Install all workspace dependencies
npm install

# Build shared packages that other packages depend on
npm run build --workspace=packages/shared

For pnpm workspaces:

#!/bin/bash
pnpm install --frozen-lockfile
pnpm -r --filter './packages/shared' build

The worktree is created at the repo level. For monorepos, the setup script handles internal topology — workspace hoisting, selective builds, symlinks. DGS has no monorepo-specific logic.

Setup Failures

If setup fails, the worktree remains in valid git state. Fix the issue and re-run:

dgs-tools worktrees setup {slug}

Useful Commands

dgs-tools worktrees list              # Show all active worktrees
dgs-tools worktrees setup {slug}      # Re-run setup for a worktree
dgs-tools worktrees prune             # Clean up orphaned worktree entries

For command details, see the User Guide.


The DGS git model — worktree isolation, rebase-before-merge, PR & merge completion, parallel consoles, and the concurrency/locking layer.