Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 390 additions & 17 deletions cmd/gitcd/init.go

Large diffs are not rendered by default.

746 changes: 746 additions & 0 deletions cmd/gitcd/init_test.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/gitcd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func run(args []string, stdout, stderr io.Writer) int {
case "path":
return runPath(args[1:], stdout, stderr)
case "init":
return runInit(args[1:], stdout, stderr)
return runInit(args[1:], os.Stdin, stdout, stderr)
case "apply":
return runApply(args[1:], stdout, stderr)
case "profile":
Expand Down
12 changes: 12 additions & 0 deletions docs/concepts/gitcd.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@
identity: a generated `includeIf` config for the common case, and a pinned local config
write for the explicit override, so a repository always has one clear, inspectable reason
for the identity it carries.
- `user.useConfigOnly` only stops git from guessing `user.name` and `user.email` from the
account's username and hostname; it never suppresses a value that is already configured,
from any source. `apply` writes it twice for that reason: unconditionally at the top of the
generated include file, so a repository no rule matches and no `defaultProfile` covers still
refuses to commit, and again inside every profile file, redundant for a matched repository
but needed on its own wherever a profile file is read outside that include chain. Neither
copy protects against a `user.name`, `user.email`, `user.signingkey`, `gpg.format` or
`commit.gpgsign` set directly in the user's own global git configuration, outside gitcd's
generated files: that value is already "configuration" by git's own definition, and applies
to every repository, one some rule matches or not. `init` detects exactly this case, reports
every such key, and, only when asked, moves it into a profile and removes it from the global
file, with a backup.
- `internal/scan` walks the configured root into repository values for `list`, `doctor` and
`status` to consume, treating a `.git` file, not only a `.git` directory, as a repository
boundary, so a linked worktree is never descended into by mistake.
Expand Down
19 changes: 19 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,25 @@ func defaultForHome(home string) (Config, error) {
return cfg, nil
}

// Path returns the configuration file Load reads: the first of its
// candidate paths that exists, or its primary candidate, "~/.gitcd.yaml"
// resolved against the calling user's home directory, when neither does.
// A caller that edits the file a person maintains by hand, "init" moving
// a global identity key into a profile for instance, writes here, so an
// edit always lands on the same file Load would read back.
func Path() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("config: find the home directory: %w", err)
}
for _, path := range candidatePaths(home) {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return candidatePaths(home)[0], nil
}

// candidatePaths lists, in the order Load tries them, the paths a
// configuration file may live at for a user whose home directory is home.
func candidatePaths(home string) []string {
Expand Down
41 changes: 41 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,47 @@ hosts:
})
}

func TestPath(t *testing.T) {
t.Run("the primary candidate when neither file exists", func(t *testing.T) {
home := withHome(t)

got, err := config.Path()
if err != nil {
t.Fatalf("Path() returned an unexpected error: %v", err)
}
if want := filepath.Join(home, ".gitcd.yaml"); got != want {
t.Fatalf("Path() = %q, want %q", got, want)
}
})

t.Run("the file Load would read when it already exists", func(t *testing.T) {
home := withHome(t)
writeFile(t, filepath.Join(home, ".gitcd.yaml"), "root: ~/elsewhere\n")

got, err := config.Path()
if err != nil {
t.Fatalf("Path() returned an unexpected error: %v", err)
}
if want := filepath.Join(home, ".gitcd.yaml"); got != want {
t.Fatalf("Path() = %q, want %q", got, want)
}
})

t.Run("the xdg fallback when only it exists", func(t *testing.T) {
home := withHome(t)
xdgPath := filepath.Join(home, ".config", "gitcd", "config.yaml")
writeFile(t, xdgPath, "root: ~/elsewhere\n")

got, err := config.Path()
if err != nil {
t.Fatalf("Path() returned an unexpected error: %v", err)
}
if got != xdgPath {
t.Fatalf("Path() = %q, want %q", got, xdgPath)
}
})
}

func TestLoad_Profiles(t *testing.T) {
t.Run("no file ships useConfigOnly true and no profiles", func(t *testing.T) {
withHome(t)
Expand Down
220 changes: 220 additions & 0 deletions internal/config/edit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright 2026 Simon Ostendorf
// SPDX-License-Identifier: Apache-2.0

package config

import (
"bytes"
"errors"
"fmt"
"io"
"sort"

"gopkg.in/yaml.v3"
)

// AddProfileKeys adds or overwrites key/value pairs under profiles.name in
// the YAML document existing holds, creating the top level "profiles"
// map, name's own map, or the document itself, whichever is missing, and
// preserving every comment and the rest of the document's structure
// otherwise. existing may be empty, in which case a fresh document is
// built.
//
// This is the file a person edits by hand, not one gitcd owns and
// regenerates, so the edit goes through gopkg.in/yaml.v3's comment
// preserving Node tree rather than a decode into a plain struct followed
// by a full re-encode, which would silently drop every comment the file
// carried.
func AddProfileKeys(existing []byte, name string, keys map[string]string) ([]byte, error) {
doc, err := parseOrEmptyDocument(existing)
if err != nil {
return nil, err
}

root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return nil, fmt.Errorf("config: the existing configuration is not a YAML mapping at its top level")
}

profiles, err := mappingChild(root, "profiles")
if err != nil {
return nil, err
}
profile, err := mappingChild(profiles, name)
if err != nil {
return nil, err
}
for _, key := range sortedStringKeys(keys) {
setScalarChild(profile, key, keys[key])
}

out, err := yaml.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("config: render the updated configuration: %w", err)
}
return out, nil
}

// ErrMultipleDocuments is wrapped into the error AddProfileKeys returns
// when the existing configuration holds more than one YAML document:
// gopkg.in/yaml.v3 decodes and re-encodes only the first one, which would
// silently drop every document after it from the file this command
// rewrites.
var ErrMultipleDocuments = errors.New("config: the existing configuration holds more than one YAML document")

// parseOrEmptyDocument parses existing as a single YAML document node, or
// returns a fresh document holding an empty mapping when existing is
// empty: a configuration file that does not exist yet, or exists but is
// blank, is not a parse error, matching LoadFrom's own treatment of a
// missing file.
//
// existing holding more than one YAML document is refused with
// ErrMultipleDocuments rather than edited: this package's own
// yaml.v3.Node round trip, like config.LoadFrom, only ever sees the
// first document, so silently writing back just that one would discard
// every document after it with nothing printed to say so. A person who
// deliberately split the file this way is expected to combine it into
// one document by hand before running a command that edits it.
func parseOrEmptyDocument(existing []byte) (*yaml.Node, error) {
if len(existing) == 0 {
return &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}},
}, nil
}

dec := yaml.NewDecoder(bytes.NewReader(existing))

var doc yaml.Node
if err := dec.Decode(&doc); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("config: parse the existing configuration: %w", err)
}

if err := nextDocumentExists(dec); err != nil {
return nil, err
}

if doc.Kind == 0 || len(doc.Content) == 0 {
// A document holding only comments, or nothing at all, decodes
// into a node with no content: the same empty case as a missing
// file, just already having gone through the parser.
return &yaml.Node{
Kind: yaml.DocumentNode,
HeadComment: doc.HeadComment,
Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}},
}, nil
}
return &doc, nil
}

// nextDocumentExists reports, as ErrMultipleDocuments, whether dec still
// holds a second YAML document after the one parseOrEmptyDocument already
// decoded. A trailing, empty document, the shape a lone "---" end marker
// with nothing meaningful after it decodes to, is not treated as a real
// second document: only one that itself carries content counts.
func nextDocumentExists(dec *yaml.Decoder) error {
var next yaml.Node
err := dec.Decode(&next)
switch {
case errors.Is(err, io.EOF):
return nil
case err != nil:
return fmt.Errorf("config: parse the existing configuration: %w", err)
case isEmptyDocument(&next):
return nil
default:
return ErrMultipleDocuments
}
}

// isEmptyDocument reports whether doc, a yaml.Node decoded as a whole
// document, carries no real content: either no document node at all, or
// a document node wrapping nothing but an implicit "!!null" scalar, the
// shape a bare "---" document separator with nothing meaningful after it
// decodes to.
func isEmptyDocument(doc *yaml.Node) bool {
if doc.Kind == 0 || len(doc.Content) == 0 {
return true
}
content := doc.Content[0]
return content.Kind == yaml.ScalarNode && content.Tag == "!!null"
}

// errNotFound is returned internally by mappingLookup when key is not
// among parent's children; mappingChild and setScalarChild both treat it
// as "create one" rather than an error, so it never escapes this file.
var errNotFound = errors.New("config: key not found in mapping")

// mappingChild returns parent's child mapping node keyed name, appending
// both the key and a fresh, empty mapping value when parent does not
// already carry one.
//
// A child that already exists but decoded as an empty "!!null" scalar is
// turned into a mapping node in place rather than returned as is: that is
// exactly the shape a bare "profiles:" or "defaultProfile:", with nothing
// after the colon, decodes to, and appending a key/value pair to a
// scalar node's Content is silently ignored by yaml.Marshal, which let
// "init" report a move that never reached the file. A child that exists
// as some other, non-mapping, non-null shape, a list or a quoted string
// for instance, is refused instead of silently coerced, since turning it
// into a mapping would discard whatever a person deliberately wrote
// there.
func mappingChild(parent *yaml.Node, name string) (*yaml.Node, error) {
value, err := mappingLookup(parent, name)
if errors.Is(err, errNotFound) {
key := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}
value = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
parent.Content = append(parent.Content, key, value)
return value, nil
}
switch {
case value.Kind == yaml.MappingNode:
return value, nil
case value.Kind == yaml.ScalarNode && value.Tag == "!!null":
value.Kind = yaml.MappingNode
value.Tag = "!!map"
value.Value = ""
value.Style = 0
value.Content = nil
return value, nil
default:
return nil, fmt.Errorf("config: %q is not a YAML mapping in the existing configuration", name)
}
}

// setScalarChild sets parent's child scalar value keyed key to value,
// updating an existing entry's value node in place so any style or
// comment that particular node already carries is kept, or appending a
// freshly built key/value pair when parent carries no such key yet.
func setScalarChild(parent *yaml.Node, key, value string) {
if existing, err := mappingLookup(parent, key); err == nil {
existing.SetString(value)
return
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}
valueNode := &yaml.Node{}
valueNode.SetString(value)
parent.Content = append(parent.Content, keyNode, valueNode)
}

// mappingLookup returns parent's value node keyed key, or errNotFound
// when parent, a mapping node, carries no such key.
func mappingLookup(parent *yaml.Node, key string) (*yaml.Node, error) {
for i := 0; i+1 < len(parent.Content); i += 2 {
if parent.Content[i].Value == key {
return parent.Content[i+1], nil
}
}
return nil, errNotFound
}

// sortedStringKeys returns m's keys sorted, so a caller writing them into
// a document a person reads gets a deterministic order.
func sortedStringKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
Loading
Loading