Skip to content

feat: identity profiles applied per remote #2

Closed

Description

@simonostendorf

Goal

A repository gets the right git identity automatically from its remote, and a single repository
can be pinned to another profile deliberately.

Context

  • Trigger: second milestone, depends on the parser and layout from the navigation core issue.
  • Affected: internal/profile, internal/gitconfig, internal/git, cmd/gitcd.

Verified git behaviour this design rests on

Probed on git 2.54.0 (Apple Git-157) with throwaway repositories, GIT_CONFIG_GLOBAL and
GIT_CONFIG_NOSYSTEM=1:

  • includeIf inside an included file resolves, so gitcd can own one generated file.
  • includeIf "hasconfig:remote.*.url:<glob>" works from inside that generated file and matched a
    repository lying outside any layout root. Rules do not depend on the path.
  • The hasconfig glob matches the URL as written. git@github.com:acme/** did not match
    https://github.com/acme/thing.git. One block per URL form is needed.
  • A later includeIf block wins over an earlier one.
  • A user.email in the global file placed after the include line beats every includeIf value.
  • ~/.gitconfig on the target machine already carries user.name, user.email,
    user.signingkey, gpg.format and commit.gpgsign before an existing [include], and there
    is no ~/.config/git/config. init merges into an existing file, it never creates a fresh one.
  • gitdir/i, onbranch, hasconfig:remote.*.url and user.useConfigOnly exist in the man page
    of the installed git.

Config surface

profiles:
  private:
    user.name: Simon Ostendorf
    user.email: simon@example.com
    user.signingkey: ssh-ed25519 AAAA...
    gpg.format: ssh
    commit.gpgsign: "true"
  ado:
    user.name: Simon Ostendorf
    user.email: simon@work.example

rules:
  - host: dev.azure.com
    profile: ado
  - host: github.com
    owner: ostendorf-cloud
    profile: private

defaultProfile: private

Booleans are quoted, the values are git config strings. The loader rejects a remote.* key in a
profile, because a file included through hasconfig may not carry remote URLs, and validates
every key against git's section.key syntax.

Two mechanisms, one job each

  1. Path and remote based, the default. gitcd apply renders one file per profile under
    ~/.config/gitcd/profiles/<name>.gitconfig and one ~/.config/gitcd/includes.gitconfig. Per
    rule it emits a gitdir/i: block for the rendered layout glob and a hasconfig block per URL
    form. The gitdir block catches a repository whose remote was renamed or removed, the
    hasconfig blocks catch one placed outside the layout. Blocks are emitted in specificity
    order, default before host before owner before repository, because the later block wins. YAML
    rule order is not emit order.
  2. Per repository, the pinned override, and nothing else. gitcd profile set <name> writes the
    keys plus a marker gitcd.profile = <name> and the written key list under gitcd.keys into
    one repository's local config. Local config beats an include by git's own precedence. The
    marker is what lets a later doctor tell a deliberate pin from stale config.

~/.gitconfig gets one include.path line, added through git config --global --add so git
chooses the file, after a backup. init is the only command that touches it, and it refuses when
a global identity key sits after the include, because that key would win silently.

Seams to define here, consumed by every later milestone

  • Repo: path, gitdir, remote, worktree flag, expected profile, matched rule, effective identity
    with its origin.
  • The resolver returns the matched rule, not only the profile name. profile show, the clone
    output and doctor all need the why.
  • internal/gitconfig renders content and never writes. Writes go through internal/git, which
    shells out to git config --global --add and git -C <path> config --local. Never write
    .git/config by path, inside a linked worktree that path is wrong.

Acceptance criteria

  • internal/profile resolves a remote to a profile and returns the matched rule.
  • internal/gitconfig renders the profile files and the include file, golden file tested,
    and writes nothing itself.
  • Blocks are emitted in specificity order, with a test proving a general rule listed last in
    YAML does not override a specific one.
  • A hasconfig block is emitted per URL form, with a test per form.
  • The rendered base profile sets user.useConfigOnly = true, with an opt out in config.
  • gitcd init adds exactly one include.path line through git config --global --add, after
    a backup, and refuses when a global identity key sits after the include.
  • gitcd apply writes the managed files, and --dry-run prints them instead.
  • gitcd profile set <name> writes the keys plus gitcd.profile and gitcd.keys locally,
    through git -C <path> config --local, never by writing .git/config by path.
  • gitcd profile unset removes exactly the keys recorded in gitcd.keys and reports
    leftovers.
  • gitcd profile show [path] prints the expected profile, the matched rule and the effective
    user.email and user.signingkey with their origin.
  • gitcd clone --profile <name> pins at clone time through git clone -c.
  • After every clone, the effective identity is read back and printed.
  • The Repo value and the resolver seam exist and are used by clone and profile show.
  • Tests cover all of the above with a controlled HOME and GIT_CONFIG_NOSYSTEM=1.
  • task check passes.

Out of scope

  • list, cd, doctor, adopt, status, sync.
  • Managing an allowed signers file for SSH signature verification.

Open question to resolve while implementing

  • Whether url.<base>.insteadOf rewriting happens before hasconfig matching. The man page reads
    as raw URLs, unverified. Probe it, it changes which URL forms must be emitted.

Activity

  1. added
    ai: readyScoped + approved for an AI agent to pick up
    ai: workingAn AI agent is implementing (incl. committed but not yet pushed)
    ai: reviewingAn AI agent is reviewing the pushed work
    ai: changes requestedAI review requested changes - back to the implement loop
    and removed
    needs triageNot yet triaged by a maintainer
    ai: readyScoped + approved for an AI agent to pick up
    ai: workingAn AI agent is implementing (incl. committed but not yet pushed)
    ai: reviewingAn AI agent is reviewing the pushed work
    on Nov 13, 2026
  2. added
    ai: reviewingAn AI agent is reviewing the pushed work
    ai: doneAI-reviewed and accepted
    and removed
    ai: changes requestedAI review requested changes - back to the implement loop
    ai: reviewingAn AI agent is reviewing the pushed work
    on Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai: doneAI-reviewed and acceptedfeatureNew feature or capabilitystack: goRelated to Go code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions