Make azd work with git worktrees (share/pin environments across agent sessions)
- Dominant language
- Go
- Stars
- 569
- Forks
- 364
- Avg merge
- 2d 19h
- Merged PRs (30d)
- 136
Description
## Problem
`azd` anchors all environment state at `ProjectDirectory()/.azure` (the directory containing `azure.yaml`, found by walking up from the cwd). Because `.azure` is gitignored, **a newly created git worktree never has it**. As a result, every worktree looks like a fresh, un-inited template: `azd` prompts to `init` / create a new environment, losing the shared environment, remote state, and already-provisioned infrastructure.
This is increasingly painful because **one git worktree per AI agent session** (GitHub Copilot CLI, VS Code agent windows, etc.) has become a common way to run multiple concurrent sessions/experiments against the same template. Today each worktree is a dead end for `azd`.
### Desired behavior
- Detect when the current folder is a **linked worktree** and there is already an environment in the **main** repo.
- By default, **transparently reuse** the main repo's environment (the `.azure` folder lives only in the main worktree because it is gitignored).
- If the main repo has **no** `.azure`, prompt to create one and write it at the main repo path (shared across worktrees).
- Allow a worktree to **use/pin its own environment without changing the main app's default**, so another session in the main app is unaffected.
## Detection mechanics (verified)
```
git rev-parse --git-common-dir # /.git (shared)
git rev-parse --git-dir # /.git/worktrees/ (per-worktree)
git rev-parse --show-toplevel #
```
- Linked worktree iff `--git-dir` != `--git-common-dir`.
- Shared main-worktree root = `dirname(--git-common-dir)` (non-bare).
- Fallback without git CLI: `.git` is a *file* (`gitdir: …`) in a worktree vs a *dir* in main; `commondir` points to the shared git dir.
- Degrade to today's behavior when: not a git repo, git missing, or bare repo with no main working tree.
## Proposed model: split "selection" from "data"
Decouple the two responsibilities currently fused into `.azure`:
1. **Env data store** (env folders + remote cache): always the **shared** `.azure` in the main worktree, at the same project-relative subpath (monorepo-safe).
2. **Selection** (which env this context uses + the default pointer): resolved per context.
`AzdContext` gains a resolved `environmentDataRoot` distinct from `projectDirectory`. Everything already funnels through `EnvironmentDirectory()` / `EnvironmentRoot(name)` / `GetDefaultEnvironmentName()`, so the blast radius is small.
### Selected-environment precedence (high → low)
1. `--environment` flag
2. `AZURE_ENV_NAME` env var
3. **worktree-local selection** (linked worktree only)
4. **shared default** (`/.azure/config.json` `defaultEnvironment`)
5. interactive prompt / init
## Locked decisions
- **D1 — Selection storage:** worktree `.azure/config.json` acts as a **pointer** (`{ defaultEnvironment, sharedRoot }`) with **no env data dirs** inside the worktree. Self-contained, already gitignored, and removed automatically by `git worktree remove`. Git is the source of truth for `sharedRoot` and refreshes a stale pointer.
- **D2 — Scope from a worktree:** `azd env new` creates the env **data in the shared store** (folder + `.env` + `config.json`), but only **this worktree** selects it; `azd env select` switches **only this worktree**. Neither changes the shared/main default. (`--scope shared` may be added later as an explicit escape hatch.)
- **D3 — Same env in two worktrees:** **warn and allow** — show an "in use by another worktree" notice (derived from `git worktree list` + each worktree pointer), but let the user proceed.
- **D4 — First use in a worktree:** **silent + provenance** — no confirmation; always print `Using environment '' from main repository ()`.
## UX flows
- **Worktree, shared `.azure` exists, has default** → use silently, always print provenance.
- **Worktree, shared `.azure` missing** → prompt: "No azd environment found in the main repository. Create one shared across worktrees?" → create env data at the main worktree root.
- **`azd env new ` in a worktree** → create env data in shared store, set worktree-local selection, leave shared default untouched.
- **`azd env select ` in a worktree** → switch only this worktree.
- **`azd env list` in a worktree** → list shared envs; distinguish shared default (★) from worktree selection (→).
## Corner cases to handle
1. Shared `.azure` missing / never inited → create at main worktree root; deterministic under `--no-prompt`.
2. Bare repo / no main working tree → fall back to standalone; document.
3. Monorepo / project in a subdir → key shared `.azure` by project path relative to worktree root; a branch that adds a NEW project not present in main → standalone.
4. `azure.yaml` moved/renamed on the worktree branch → relative path differs; no match in main → standalone.
5. **Two worktrees select the same env** (biggest hazard) → concurrent provision/deploy can corrupt state / race Azure. flock guards local files only → warn-and-allow per D3.
6. Surprise deployments → always surface resolved env + provenance.
7. Worktree already has REAL `.azure` env data (manually copied / older azd) → treat as standalone (back-compat); only redirect when the worktree has no `.azure` or only a pointer.
8. `AZURE_ENV_NAME` / `--environment` must still win over worktree selection and shared default; unknown name → offer to create in shared store.
9. `git worktree remove` → pointer removed with the dir (no orphan cleanup needed under D1).
10. No git / git not installed / non-git dir → exactly today's behavior.
11. Path normalization / symlinks (macOS `/var`→`/private/var`) → abs + EvalSymlinks before compare / relpath.
12. Worktree pointer files must be gitignored (the `.azure` dir already is).
13. Remote env backend (`m.remote`) → keep the shared local cache at the shared `.azure` so both worktrees share it.
14. `azd init` inside a worktree → `azure.yaml` already tracked; re-init OK, but env creation targets shared store.
15. CI / `--no-prompt` → deterministic: shared default if present; else error-with-suggestion (never hang).
16. Two "defaults" in UX → clearly distinguish shared default vs worktree selection everywhere.
17. Paths with spaces → quote in all user-facing messages.
## Implementation surface
- New `internal/gitrepo` helper: `IsWorktree()`, `CommonDir()`, `MainWorktreeRoot()`, `WorktreeID()`, with git CLI + manual `.git`-file fallback; no hard fail when git is absent.
- `AzdContext`: resolve `environmentDataRoot` separately from `projectDirectory`; `EnvironmentDirectory()`/`EnvironmentRoot()` return the shared data root; add a selection/pointer dir; worktree-aware `GetDefaultEnvironmentName()` and a scoped `SetProjectState(state, scope)`.
- `env` commands (`new`, `select`, `list`): add `--scope worktree|shared`; provenance messaging; "in use by another worktree" warning.
- Telemetry (per `cli/azd/AGENTS.md` checklist): bool "in worktree" + enum "env source" (flag/envvar/worktree/shared/prompt). Enums/bools only — nothing hashed. Update all telemetry docs + coverage test.
- Roll out behind an **alpha feature flag** (`alpha.worktrees`) to contain surprise-deploy risk.
## Suggested phasing
1. **Phase 1 — Detection + transparent reuse.** `alpha.worktrees` feature flag + `internal/gitrepo` detection helper; `AzdContext` data-root/selection split + worktree-aware default resolution (silent reuse + provenance); worktree pointer (D1); `env new`/`env select` scope behavior (D2).
2. **Phase 2 — `azd env list` worktree awareness.** When the repo has at least one worktree, enrich `azd env list` so users can see how environments map to worktrees (see below). Surface the "in use by another worktree" signal here (D3).
3. **Phase 3 — Telemetry + docs** (`environment-variables.md`, feature-status, design doc under `cli/azd/docs/design/`).
### Phase 2 detail: `azd env list` worktree info
Output is unchanged unless `git worktree list` reports **more than one** worktree.
- **Table mode:** add a `WORKTREE(S)` column showing which worktree(s) currently select each env (worktree name/relative path). Keep distinguishing the shared default (★) from each worktree's selection (→); highlight the env selected by the current worktree; mark envs "in use by another worktree" (ties into D3).
- **JSON mode** (`--output json`): add per-env fields, e.g. `selectedByWorktrees: ["", …]`, `isSharedDefault: bool`, `isCurrentWorktreeSelection: bool`. Match existing JSON field/type conventions; populate only when worktrees exist.
- **Data source:** `git worktree list --porcelain` cross-referenced with each worktree's `.azure/config.json` pointer (`defaultEnvironment`). Normalize paths (abs + EvalSymlinks); tolerate stale/removed worktrees.
- **Corner cases:** worktree with no pointer yet (falls back to shared default — show as such); paths with spaces (quote); bare repo / no main working tree (degrade to today's output); many worktrees (comma-join/count in table, full detail in `--output json`).
## Open follow-ups
- Whether/when to expose `--scope shared` as an explicit override.
- Exact shape of the "in use by another worktree" detection and message.
Contributor guide
Assessment
This issue has not been assessed yet.