From af5aa498c77d2d677f6484fe0b73b7535d00774e Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Tue, 26 May 2026 19:46:25 +0000 Subject: [PATCH] feat(cli): land hidden and global-default visibility modes on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the --visibility=hidden and --visibility=global-default modes (plus the --private alias) onto main. These were merged into the feat/visibility-local-51 base branch as #57 and #61 but never reached main: the #54 squash-merge landed only the local mode, so the stacked hidden/global-default work was left stranded on the base branch. This re-homes the already-reconciled implementation from feat/visibility-local-51 onto current main: - internal/gitconfig/ — machine-wide git excludes writer (new package). - internal/gitignore/ — EnsureHidden / HiddenPath / exported Upsert for the shared block, with tests. - internal/cli/cli.go — parseVisibility accepts all four modes, resolveVisibility folds in the --private alias, applyVisibility dispatches local/hidden via a file writer and global-default via internal/gitconfig with a machine-wide warning and force-add hint. - docs/cli.md and README.md — document all four modes and --private, preserving the scaffold-output ("Output") section added by #64. Scaffold output behavior from #64 (printSummary, color gate, single "Done.", clean symlink paths) is left untouched: this change does not modify internal/scaffold/ or the flavor NextSteps literals. Refs #57 #61 Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/CODEBASE.md | 121 +++++++------ README.md | 3 +- docs/cli.md | 5 +- internal/cli/cli.go | 154 +++++++++++++--- internal/cli/cli_test.go | 260 ++++++++++++++++++++++++++- internal/gitconfig/gitconfig.go | 166 +++++++++++++++++ internal/gitconfig/gitconfig_test.go | 241 +++++++++++++++++++++++++ internal/gitignore/gitignore.go | 56 +++++- internal/gitignore/gitignore_test.go | 117 ++++++++++++ 9 files changed, 1031 insertions(+), 92 deletions(-) create mode 100644 internal/gitconfig/gitconfig.go create mode 100644 internal/gitconfig/gitconfig_test.go diff --git a/.agent/CODEBASE.md b/.agent/CODEBASE.md index 6c73a57..d85108d 100644 --- a/.agent/CODEBASE.md +++ b/.agent/CODEBASE.md @@ -47,12 +47,19 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ | | |-- projectmgmt | | |-- registry.go | | `-- registry_test.go +| |-- gitconfig +| | |-- gitconfig.go +| | `-- gitconfig_test.go | |-- gitignore | | |-- gitignore.go | | `-- gitignore_test.go | |-- scaffold +| | |-- color_test.go | | |-- scaffold.go -| | `-- scaffold_test.go +| | |-- scaffold_test.go +| | |-- term_darwin.go +| | |-- term_linux.go +| | `-- term_other.go | |-- testflags | | `-- testflags.go | `-- trackers @@ -82,9 +89,9 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ ## Public API surface ### Go -internal/cli/cli.go:52:type Version struct { -internal/cli/cli.go:135:type App struct { -internal/cli/cli.go:143:func New(out, errOut io.Writer, version Version) App { +internal/cli/cli.go:66:type Version struct { +internal/cli/cli.go:152:type App struct { +internal/cli/cli.go:160:func New(out, errOut io.Writer, version Version) App { internal/cli/cli_test.go:15:func TestListFlavors(t *testing.T) { internal/cli/cli_test.go:28:func TestInitLegacyTargetArgument(t *testing.T) { internal/cli/cli_test.go:47:func TestInitLegacyTargetArgumentWithoutFlag(t *testing.T) { @@ -97,25 +104,35 @@ internal/cli/cli_test.go:149:func TestInitVisibilityLocalAppendsGitignoreBlock(t internal/cli/cli_test.go:176:func TestInitVisibilityLocalIsIdempotent(t *testing.T) { internal/cli/cli_test.go:197:func TestInitVisibilitySharedLeavesNoGitignore(t *testing.T) { internal/cli/cli_test.go:214:func TestInitVisibilityLocalRejectedOnDocCollabFlavor(t *testing.T) { -internal/cli/cli_test.go:227:func TestInitVisibilityUnimplementedModesRejected(t *testing.T) { -internal/cli/cli_test.go:244:func TestInitVisibilityUnknownValueRejected(t *testing.T) { -internal/cli/cli_test.go:256:func TestInitVisibilityLocalDryRunWritesNothing(t *testing.T) { -internal/cli/cli_test.go:273:func TestRejectsUnknownCommandTypo(t *testing.T) { -internal/cli/cli_test.go:283:func TestListTrackers(t *testing.T) { -internal/cli/cli_test.go:299:func TestAddTrackerWritesFilesAndMergesMCP(t *testing.T) { -internal/cli/cli_test.go:330:func TestAddTrackerShipsNoSecretLiteralAndGuidesCredentials(t *testing.T) { -internal/cli/cli_test.go:380:func TestAddTrackerIsIdempotent(t *testing.T) { -internal/cli/cli_test.go:402:func TestAddTrackerMultipleCoexist(t *testing.T) { -internal/cli/cli_test.go:426:func TestAddTrackerRejectsMissingScaffold(t *testing.T) { -internal/cli/cli_test.go:440:func TestAddTrackerRejectsUnknownTracker(t *testing.T) { -internal/cli/cli_test.go:453:func TestTopLevelHelpListsAllSubcommands(t *testing.T) { -internal/cli/cli_test.go:480:func TestSubcommandHelpPrintsFlagsAndExamples(t *testing.T) { -internal/cli/cli_test.go:521:func TestFlaglessSubcommandHelp(t *testing.T) { -internal/cli/cli_test.go:539:func TestUnknownCommandErrorPointsAtHelp(t *testing.T) { -internal/cli/cli_test.go:551:func TestUnknownFlavorErrorPointsAtHelp(t *testing.T) { -internal/cli/cli_test.go:567:func TestHelpFlagsMatchDocs(t *testing.T) { -internal/cli/cli_test.go:594:func TestVersion(t *testing.T) { -internal/cli/cli_test.go:607:func TestVersionDefaultsToDev(t *testing.T) { +internal/cli/cli_test.go:237:func TestInitVisibilityHiddenWritesGitInfoExclude(t *testing.T) { +internal/cli/cli_test.go:272:func TestInitPrivateAliasMatchesHidden(t *testing.T) { +internal/cli/cli_test.go:298:func TestInitPrivateConflictsWithVisibility(t *testing.T) { +internal/cli/cli_test.go:326:func TestInitVisibilityHiddenIsIdempotent(t *testing.T) { +internal/cli/cli_test.go:361:func TestInitVisibilityGlobalDefaultWritesExcludesAndWarns(t *testing.T) { +internal/cli/cli_test.go:395:func TestInitVisibilityGlobalDefaultIsIdempotent(t *testing.T) { +internal/cli/cli_test.go:415:func TestInitVisibilityHiddenRejectedOnDocCollabFlavor(t *testing.T) { +internal/cli/cli_test.go:428:func TestInitPrivateRejectedOnDocCollabFlavor(t *testing.T) { +internal/cli/cli_test.go:441:func TestInitVisibilityHiddenDryRunWritesNothing(t *testing.T) { +internal/cli/cli_test.go:458:func TestInitVisibilityGlobalDefaultDryRunWritesNothing(t *testing.T) { +internal/cli/cli_test.go:479:func TestInitVisibilityGlobalDefaultRejectedOnDocCollabFlavor(t *testing.T) { +internal/cli/cli_test.go:492:func TestInitVisibilityUnknownValueRejected(t *testing.T) { +internal/cli/cli_test.go:504:func TestInitVisibilityLocalDryRunWritesNothing(t *testing.T) { +internal/cli/cli_test.go:521:func TestRejectsUnknownCommandTypo(t *testing.T) { +internal/cli/cli_test.go:531:func TestListTrackers(t *testing.T) { +internal/cli/cli_test.go:547:func TestAddTrackerWritesFilesAndMergesMCP(t *testing.T) { +internal/cli/cli_test.go:578:func TestAddTrackerShipsNoSecretLiteralAndGuidesCredentials(t *testing.T) { +internal/cli/cli_test.go:628:func TestAddTrackerIsIdempotent(t *testing.T) { +internal/cli/cli_test.go:650:func TestAddTrackerMultipleCoexist(t *testing.T) { +internal/cli/cli_test.go:674:func TestAddTrackerRejectsMissingScaffold(t *testing.T) { +internal/cli/cli_test.go:688:func TestAddTrackerRejectsUnknownTracker(t *testing.T) { +internal/cli/cli_test.go:701:func TestTopLevelHelpListsAllSubcommands(t *testing.T) { +internal/cli/cli_test.go:728:func TestSubcommandHelpPrintsFlagsAndExamples(t *testing.T) { +internal/cli/cli_test.go:769:func TestFlaglessSubcommandHelp(t *testing.T) { +internal/cli/cli_test.go:787:func TestUnknownCommandErrorPointsAtHelp(t *testing.T) { +internal/cli/cli_test.go:799:func TestUnknownFlavorErrorPointsAtHelp(t *testing.T) { +internal/cli/cli_test.go:815:func TestHelpFlagsMatchDocs(t *testing.T) { +internal/cli/cli_test.go:842:func TestVersion(t *testing.T) { +internal/cli/cli_test.go:855:func TestVersionDefaultsToDev(t *testing.T) { internal/flavors/claudecowork/flavor.go:11:func Templates() embed.FS { internal/flavors/claudecowork/flavor.go:18:func ExecutablePaths() []string { internal/flavors/claudecowork/flavor.go:25:func NextSteps(target string) string { @@ -142,51 +159,41 @@ internal/flavors/registry.go:16:type Registry struct { internal/flavors/registry.go:20:func DefaultRegistry() Registry { internal/flavors/registry.go:153:func NewRegistry(items ...Flavor) Registry { internal/flavors/registry_test.go:18:func TestAllTemplatesParse(t *testing.T) { +internal/gitconfig/gitconfig.go:27:type Runner interface { +internal/gitconfig/gitconfig.go:37:type Env interface { +internal/gitconfig/gitconfig.go:54:func EnsureGlobal(runner Runner, env Env, upsert upsertFunc) (string, error) { +internal/gitconfig/gitconfig.go:83:func GlobalPath(runner Runner, env Env) (string, error) { +internal/gitconfig/gitconfig.go:136:type OSEnv struct{} +internal/gitconfig/gitconfig.go:145:func NewExecRunner() Runner { return execRunner{} } +internal/gitconfig/gitconfig_test.go:98:func TestEnsureGlobalCreatesDefaultPathAndSetsKey(t *testing.T) { +internal/gitconfig/gitconfig_test.go:121:func TestEnsureGlobalHonorsXDGConfigHome(t *testing.T) { +internal/gitconfig/gitconfig_test.go:140:func TestEnsureGlobalHonorsConfiguredExcludesFileAndDoesNotSetKey(t *testing.T) { +internal/gitconfig/gitconfig_test.go:162:func TestEnsureGlobalExpandsTildeInConfiguredPath(t *testing.T) { +internal/gitconfig/gitconfig_test.go:176:func TestEnsureGlobalAppendsToExistingFileWithoutOurBlock(t *testing.T) { +internal/gitconfig/gitconfig_test.go:198:func TestEnsureGlobalIsIdempotent(t *testing.T) { +internal/gitconfig/gitconfig_test.go:223:func TestGlobalPathDoesNotWriteOrSet(t *testing.T) { internal/gitignore/gitignore.go:48:func Block() string { internal/gitignore/gitignore.go:63:func LocalPath(target string) (string, error) { internal/gitignore/gitignore.go:75:func EnsureLocal(target string) (string, error) { +internal/gitignore/gitignore.go:94:func HiddenPath(target string) (string, error) { +internal/gitignore/gitignore.go:115:func EnsureHidden(target string) (string, error) { +internal/gitignore/gitignore.go:139:func Upsert(content string) string { internal/gitignore/gitignore_test.go:10:func TestBlockHasMarkersAndEnvelope(t *testing.T) { internal/gitignore/gitignore_test.go:27:func TestEnsureLocalCreatesAndAppends(t *testing.T) { internal/gitignore/gitignore_test.go:70:func TestEnsureLocalIsIdempotent(t *testing.T) { internal/gitignore/gitignore_test.go:95:func TestEnsureLocalReplacesStaleBlockInPlace(t *testing.T) { -internal/scaffold/scaffold.go:18:type Options struct { -internal/scaffold/scaffold.go:41:func Run(ctx context.Context, opts Options) error { -internal/scaffold/scaffold.go:76:func Overlay(opts Options, fsys fs.FS, root string) error { -internal/scaffold/scaffold_test.go:18:func TestRunWritesFullstackScaffold(t *testing.T) { -internal/scaffold/scaffold_test.go:43:func TestRunSkipsExistingFilesUnlessForced(t *testing.T) { -internal/scaffold/scaffold_test.go:74:func TestRunDryRunDoesNotWriteFiles(t *testing.T) { -internal/scaffold/scaffold_test.go:98:func TestRunForceReplacesSymlinkWithoutWritingThroughIt(t *testing.T) { -internal/scaffold/scaffold_test.go:122:func TestRunForceRestoresExecutableMode(t *testing.T) { -internal/scaffold/scaffold_test.go:145:func TestRunForceRefusesToReplaceDirectory(t *testing.T) { -internal/scaffold/scaffold_test.go:163:func TestRunRendersPathTemplate(t *testing.T) { -internal/scaffold/scaffold_test.go:186:func TestRunLayersFlavorOverCommon(t *testing.T) { -internal/scaffold/scaffold_test.go:223:func TestRunAgentsOnlySkipsFreshOnlyPathsAndPrefersVariant(t *testing.T) { -internal/scaffold/scaffold_test.go:265:func TestRunFreshModeIgnoresAgentsOnlyVariants(t *testing.T) { -internal/scaffold/scaffold_test.go:295:func TestRunDoesNotInitNestedGitRepo(t *testing.T) { -internal/trackers/ado/ado.go:16:func Templates() embed.FS { -internal/trackers/ado/ado.go:25:func MCPServer() map[string]any { -internal/trackers/gh/gh.go:13:func Templates() embed.FS { -internal/trackers/gh/gh.go:21:func MCPServer() map[string]any { -internal/trackers/jira/jira.go:14:func Templates() embed.FS { -internal/trackers/jira/jira.go:23:func MCPServer() map[string]any { -internal/trackers/mcp.go:21:func MergeMCPServer(target, serverKey string, serverConfig map[string]any) (bool, error) { -internal/trackers/mcp_test.go:13:func TestMergeMCPServerAddsNewEntryToEmptyConfig(t *testing.T) { -internal/trackers/mcp_test.go:35:func TestMergeMCPServerIsIdempotent(t *testing.T) { -internal/trackers/mcp_test.go:62:func TestMergeMCPServerCreatesFileIfMissing(t *testing.T) { -internal/trackers/mcp_test.go:80:func TestMergeMCPServerErrorsOnMalformedJSON(t *testing.T) { -internal/trackers/mcp_test.go:91:func TestMergeMCPServerCreatesMissingMcpServersKey(t *testing.T) { -internal/trackers/mcp_test.go:116:func TestDefaultRegistryListsAllShippedTrackers(t *testing.T) { -internal/trackers/mcp_test.go:137:func TestNoTrackerShipsEmptySecretLiteral(t *testing.T) { -internal/trackers/mcp_test.go:161:func TestTrackerSecretsUseEnvReference(t *testing.T) { -internal/trackers/mcp_test.go:195:func TestRegistryGetReturnsKnownTrackersInError(t *testing.T) { -internal/trackers/registry.go:13:type Registry struct { -internal/trackers/registry.go:18:func DefaultRegistry() Registry { -internal/trackers/registry.go:51:func NewRegistry(items ...Tracker) Registry { +internal/gitignore/gitignore_test.go:128:func TestEnsureHiddenCreatesAndAppends(t *testing.T) { +internal/gitignore/gitignore_test.go:174:func TestEnsureHiddenIsIdempotent(t *testing.T) { +internal/gitignore/gitignore_test.go:196:func TestEnsureHiddenReplacesStaleBlockInPlace(t *testing.T) { +internal/gitignore/gitignore_test.go:234:func TestEnsureHiddenWritesNoGitignore(t *testing.T) { +internal/scaffold/color_test.go:9:func TestColorDisabledForNonTTYOutputs(t *testing.T) { +internal/scaffold/color_test.go:26:func TestColorDisabledByEnvironment(t *testing.T) { +internal/scaffold/color_test.go:50:func TestColorEnabledForTerminalFile(t *testing.T) { ## Stats ``` -Total tracked files: 342 +Total tracked files: 350 ``` diff --git a/README.md b/README.md index 13dfc8d..8d0cb1b 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ Flags for `init`: - `--no-git` — skip `git init` when the target is not already a repository. - `--dry-run` — print planned writes without changing files. - `--agents-only` — skip the flavor's fresh-project files; ship only the agentic envelope (AGENTS.md, scripts, devcontainer, Justfile, pre-commit). For adding `agent-init` to an existing project. Supported on every code flavor: `fullstack`, `go-cli`, `go-backend`, `iac`. See [`docs/flavors/go-cli.md`](./docs/flavors/go-cli.md) for a worked example. -- `--visibility` — how the scaffold is tracked by git. `shared` (default) commits it. `local` appends a fenced, idempotent block to the committed `.gitignore` so the scaffold is ignored while the ignore rule stays visible to the team. Code flavors only. See [`docs/cli.md`](./docs/cli.md). +- `--visibility` — how the scaffold is tracked by git. `shared` (default) commits it. `local` appends a fenced, idempotent block to the committed `.gitignore` so the scaffold is ignored while the ignore rule stays visible to the team. `hidden` writes the same block to the never-committed `.git/info/exclude`, leaving no trace for other contributors. `global-default` writes the same block to your machine-wide git excludes file, ignoring the scaffold in every repository on the machine; it prints a warning and the path it edited, and you force-add the scaffold to commit it openly in a given repo. Code flavors only. See [`docs/cli.md`](./docs/cli.md). +- `--private` — alias for `--visibility=hidden`. The `add-tracker` subcommand extends a `project-management` scaffold with a Jira / Azure DevOps / GitHub integration. Each call writes an `integrations//` cheatsheet and merges an entry into the target's `.mcp.json`. Idempotent and additive — multiple trackers can coexist (useful during migrations). See [`docs/cli.md`](./docs/cli.md) and [`docs/flavors/project-management.md`](./docs/flavors/project-management.md) for details. diff --git a/docs/cli.md b/docs/cli.md index c4ce4c9..93f5c2f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -31,7 +31,8 @@ agent-init ./my-tool # path-only form; implies fullstack | `--no-git` | Skip `git init` when the target is not already a repo. | | `--dry-run` | Print planned writes without changing files. | | `--agents-only` | Skip the flavor's fresh-project files; ship only the agentic envelope (AGENTS.md, scripts, devcontainer, Justfile, pre-commit). For adding agents to an existing project. Supported on every code flavor: `fullstack`, `go-cli`, `go-backend`, `iac`. Rejected on doc-collab flavors (`claude-cowork`, `project-management`) since they don't bootstrap a project layout. | -| `--visibility` | How the scaffold is tracked by git. Four planned modes; this build ships `shared` and `local`. `shared` (default) commits the scaffold normally. `local` appends a fenced, idempotent block to the committed `.gitignore` so the team sees the scaffold is ignored but does not carry the files. Code flavors only; rejected on doc-collab flavors. `hidden` (per-repo `.git/info/exclude`) and `global-default` (machine-wide excludes) are accepted spellings but error until they land. | +| `--visibility` | How the scaffold is tracked by git. Four modes, all implemented: `shared`, `local`, `hidden`, `global-default`. `shared` (default) commits the scaffold normally. `local` appends a fenced, idempotent block to the committed `.gitignore` so the team sees the scaffold is ignored but does not carry the files. `hidden` writes the same block to the never-committed `.git/info/exclude`, leaving zero committed trace (per-repo, local to your clone). `global-default` writes the same block to your **machine-wide** git excludes file, ignoring the scaffold in **every** repository on the machine. Code flavors only; rejected on doc-collab flavors. | +| `--private` | Alias for `--visibility=hidden`. Passing it alongside a conflicting `--visibility` errors. | ### Behavior @@ -39,6 +40,8 @@ agent-init ./my-tool # path-only form; implies fullstack - After file writes: creates the flavor's declared symlinks (code flavors get the AGENTS.md/CLAUDE.md trio; doc-collab flavors get none), then runs `git init` unless `--no-git`, then prints the flavor's `NextSteps` message. - With `--agents-only`: paths listed in the flavor's `FreshOnlyPaths` are skipped, and any template named `.agents-only.` (e.g. `Justfile.agents-only.tmpl`) is written as `.` in place of the base. See [docs/flavors/go-cli.md](./flavors/go-cli.md) for a worked example. - With `--visibility=local`: after the scaffold is written (symlinks and `git init` included — visibility controls tracking, not creation), a fenced block is appended to the committed `.gitignore`, creating it if absent. The block covers the agentic envelope (`.agent/`, `/AGENTS.md`, `/CLAUDE.md`, `.devcontainer/`, `/Justfile`, `.pre-commit-config.yaml`). It is delimited by `# >>> agent-init (private) >>>` / `# <<< agent-init <<<` markers, so re-running replaces it in place (never duplicates) and it can be removed by hand to undo. `init` prints the absolute path it edited. `--dry-run` previews the path and block, writing nothing. Block management lives in [internal/gitignore](../internal/gitignore/gitignore.go). +- With `--visibility=hidden` (or `--private`): the identical block is written to `.git/info/exclude` instead of `.gitignore`, creating the `.git/info` directory if absent. `.git/info/exclude` is git's per-repo, never-committed ignore file, so a teammate cloning the repo sees no agent-init trace. The mode is otherwise the same as `local`: idempotent in-place replacement, the absolute path is announced, `--dry-run` previews and writes nothing, and the symlink trio is still created (visibility controls tracking, not creation). Because `.git/info/exclude` does not appear in `git diff`, remember to remove the fenced block by hand to undo. +- With `--visibility=global-default`: the **same** fenced block is written to your machine-wide git excludes file instead of a repo file, so the scaffold is ignored in **every** git repository on the machine. **This is action-at-a-distance.** The command prints a loud machine-wide warning on stderr and always announces the absolute path it edited. The target file is `git config --global core.excludesfile` if set (honored even when it points somewhere unusual); otherwise `${XDG_CONFIG_HOME:-~/.config}/git/ignore`, which is created and set as `core.excludesfile` only when no global excludes is configured. No other global-config key is touched. Idempotent (the marked block is replaced in place) and reversible (remove the block by hand). `--dry-run` resolves and prints the target path and the block but writes nothing and touches no git config. To commit the scaffold openly in a specific repo despite the global default, force-add it there — `git add -f .agent AGENTS.md CLAUDE.md .devcontainer Justfile .pre-commit-config.yaml` — since git never re-ignores a tracked file (gitignore negation cannot re-include a file under an excluded directory, so force-add is the documented override). The global excludes-file resolution lives in [internal/gitconfig](../internal/gitconfig/gitconfig.go); the block content is shared from [internal/gitignore](../internal/gitignore/gitignore.go). - Source: [scaffold.go:31](../internal/scaffold/scaffold.go#L31) (`Run`), [cli.go:applyVisibility](../internal/cli/cli.go). ### Output diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f683e3c..3c69edc 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -13,16 +13,16 @@ import ( "strings" "github.com/Lillevang/agent-init/internal/flavors" + "github.com/Lillevang/agent-init/internal/gitconfig" "github.com/Lillevang/agent-init/internal/gitignore" "github.com/Lillevang/agent-init/internal/scaffold" "github.com/Lillevang/agent-init/internal/trackers" ) // visibility selects how the scaffold's agentic envelope is tracked by git. -// The flag has four planned values; this build implements "shared" (default) -// and "local". "hidden" and "global-default" are recognized so the flag -// surface stays stable, but rejected with a not-yet-implemented error until -// their follow-up issues (#53, #52) land. +// The flag has four values, all implemented: "shared" (default, committed), +// "local" (committed .gitignore), "hidden" (.git/info/exclude, no committed +// trace), and "global-default" (machine-wide git excludes, every repo). type visibility string const ( @@ -32,20 +32,34 @@ const ( visibilityGlobalDefault visibility = "global-default" ) -// parseVisibility validates the --visibility value. Unimplemented-but-planned -// modes return a distinct error so the message guides the user rather than -// listing them as unknown. +// parseVisibility validates the --visibility value. All four modes are +// implemented; an unknown value errors with the list of known modes. func parseVisibility(v string) (visibility, error) { switch visibility(v) { - case visibilityShared, visibilityLocal: + case visibilityShared, visibilityLocal, visibilityHidden, visibilityGlobalDefault: return visibility(v), nil - case visibilityHidden, visibilityGlobalDefault: - return "", fmt.Errorf("--visibility=%s is not implemented yet; use shared or local", v) default: - return "", fmt.Errorf("unknown --visibility %q (known: shared, local)", v) + return "", fmt.Errorf("unknown --visibility %q (known: shared, local, hidden, global-default)", v) } } +// resolveVisibility folds the --private boolean alias into the --visibility +// value. --private means --visibility=hidden. Passing it alongside an +// explicitly-set --visibility that isn't "hidden" is a conflict and errors, so +// the two flags can't silently disagree. visibilitySet distinguishes an +// explicit --visibility=shared (a real conflict with --private) from the +// unset default (no conflict — --private simply selects hidden). The default +// (no --private) just parses --visibility. +func resolveVisibility(v string, visibilitySet, private bool) (visibility, error) { + if !private { + return parseVisibility(v) + } + if visibilitySet && visibility(v) != visibilityHidden { + return "", fmt.Errorf("--private conflicts with --visibility=%s; --private is an alias for --visibility=hidden", v) + } + return visibilityHidden, nil +} + // docsURL points users at the full documentation. Surfaced in top-level help. const docsURL = "https://github.com/Lillevang/agent-init/tree/main/docs" @@ -84,13 +98,16 @@ var commands = []commandHelp{ {"--no-git", "skip git init when the target is not already a repo"}, {"--dry-run", "print planned writes without changing files"}, {"--agents-only", "ship only the agentic envelope (skip fresh-project files); rejected on claude-cowork and project-management"}, - {"--visibility", "shared (default, committed) or local (ignore the scaffold in the committed .gitignore); code flavors only"}, + {"--visibility", "shared (default, committed), local (ignore in committed .gitignore), hidden (ignore in .git/info/exclude, no committed trace), or global-default (ignore in machine-wide git excludes — affects EVERY repo); code flavors only"}, + {"--private", "alias for --visibility=hidden (hide the scaffold in this repo with no committed trace)"}, }, examples: []string{ - "agent-init init # scaffold fullstack into .", - "agent-init init go-cli ./my-tool # scaffold go-cli into ./my-tool", - "agent-init init --agents-only go-cli # add agents to an existing project", - "agent-init init --visibility=local go-cli # ignore the scaffold in .gitignore", + "agent-init init # scaffold fullstack into .", + "agent-init init go-cli ./my-tool # scaffold go-cli into ./my-tool", + "agent-init init --agents-only go-cli # add agents to an existing project", + "agent-init init --visibility=local go-cli # ignore the scaffold in .gitignore", + "agent-init init --private go-cli # hide the scaffold via .git/info/exclude", + "agent-init init --visibility=global-default go-cli # ignore in machine-wide git excludes (all repos)", }, }, { @@ -242,14 +259,21 @@ func (a App) runInit(ctx context.Context, args []string) error { noGit := flags.Bool("no-git", false, "skip git init when target is not already a repo") dryRun := flags.Bool("dry-run", false, "print what would happen without writing files") agentsOnly := flags.Bool("agents-only", false, "ship only the agentic envelope (skip fresh-project files); for adding agents to an existing project") - visibilityFlag := flags.String("visibility", string(visibilityShared), "scaffold visibility: shared (committed) or local (ignore in committed .gitignore)") + visibilityFlag := flags.String("visibility", string(visibilityShared), "scaffold visibility: shared (committed), local (ignore in committed .gitignore), hidden (ignore in .git/info/exclude), or global-default (ignore in machine-wide git excludes; affects every repo)") + private := flags.Bool("private", false, "alias for --visibility=hidden") if err := flags.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } - vis, err := parseVisibility(*visibilityFlag) + visibilitySet := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "visibility" { + visibilitySet = true + } + }) + vis, err := resolveVisibility(*visibilityFlag, visibilitySet, *private) if err != nil { return err } @@ -298,29 +322,109 @@ func (a App) runInit(ctx context.Context, args []string) error { // applyVisibility writes the scaffold's ignore envelope according to the chosen // visibility mode. "shared" (the default) is a no-op: the scaffold is committed -// normally. "local" appends a fenced, idempotent block to the committed -// .gitignore so the team sees the scaffold is ignored without carrying the -// files. The side effect is announced (the absolute path is printed). +// normally. "local" and "hidden" append a fenced, idempotent block to a +// repo-local ignore file (the committed .gitignore, or the never-committed +// .git/info/exclude respectively) and share the file-writer path. +// "global-default" writes the same block to the user's machine-wide git +// excludes file, affecting every repo, and is routed separately because it goes +// through internal/gitconfig and prints the machine-wide warning and force-add +// hint. Every mutating mode announces the absolute path it edited. func (a App) applyVisibility(vis visibility, target string, dryRun bool) error { - if vis != visibilityLocal { + switch vis { + case visibilityLocal, visibilityHidden: + return a.applyFileVisibility(vis, target, dryRun) + case visibilityGlobalDefault: + return a.applyGlobalVisibility(dryRun) + default: + return nil + } +} + +// applyFileVisibility appends the ignore block to a repo-local ignore file +// selected by the visibility mode (committed .gitignore for "local", +// .git/info/exclude for "hidden"). --dry-run previews the path and block, +// writing nothing. +func (a App) applyFileVisibility(vis visibility, target string, dryRun bool) error { + pathFn, ensureFn, ok := visibilityWriters(vis) + if !ok { return nil } if dryRun { - path, err := gitignore.LocalPath(target) + path, err := pathFn(target) if err != nil { return err } _, _ = fmt.Fprintf(a.out, " ignore %s (dry-run):\n%s", path, indentBlock(gitignore.Block())) return nil } - path, err := gitignore.EnsureLocal(target) + path, err := ensureFn(target) if err != nil { - return fmt.Errorf("applying --visibility=local: %w", err) + return fmt.Errorf("applying --visibility=%s: %w", vis, err) } _, _ = fmt.Fprintf(a.out, " ignore %s (agent-init scaffold block)\n", path) return nil } +// visibilityWriters maps a repo-local visibility mode to the gitignore +// functions that compute its target path (dry-run) and write its block. The +// third return is false for modes that don't touch a repo-local ignore file +// (shared, and global-default, which goes through internal/gitconfig instead). +func visibilityWriters(vis visibility) (path func(string) (string, error), ensure func(string) (string, error), ok bool) { + switch vis { + case visibilityLocal: + return gitignore.LocalPath, gitignore.EnsureLocal, true + case visibilityHidden: + return gitignore.HiddenPath, gitignore.EnsureHidden, true + default: + return nil, nil, false + } +} + +// applyGlobalVisibility writes the ignore block to the user's machine-wide git +// excludes file (core.excludesfile, or ~/.config/git/ignore if unset). This is +// action-at-a-distance: it ignores the agentic envelope in EVERY repository on +// the machine, so the warning and the edited path are printed loudly. To commit +// the scaffold openly in a specific repo despite this default, force-add it +// (see the printed hint). --dry-run resolves and prints the target path and the +// block but writes nothing and touches no git config. +func (a App) applyGlobalVisibility(dryRun bool) error { + runner := gitconfig.NewExecRunner() + env := gitconfig.OSEnv{} + a.warnGlobalVisibility() + if dryRun { + path, err := gitconfig.GlobalPath(runner, env) + if err != nil { + return fmt.Errorf("resolving global excludes path: %w", err) + } + _, _ = fmt.Fprintf(a.out, " ignore %s (machine-wide, dry-run):\n%s", path, indentBlock(gitignore.Block())) + return nil + } + path, err := gitconfig.EnsureGlobal(runner, env, gitignore.Upsert) + if err != nil { + return fmt.Errorf("applying --visibility=global-default: %w", err) + } + _, _ = fmt.Fprintf(a.out, " ignore %s (machine-wide git excludes — affects EVERY repo)\n", path) + a.printForceAddHint() + return nil +} + +// warnGlobalVisibility prints the unmissable machine-wide warning before the +// global excludes file is touched (or previewed). A global write affects every +// repository on the machine, so it must never happen silently. +func (a App) warnGlobalVisibility() { + _, _ = fmt.Fprintln(a.errOut, "WARNING: --visibility=global-default edits your MACHINE-WIDE git excludes.") + _, _ = fmt.Fprintln(a.errOut, " The agent-init scaffold will be ignored in EVERY git repository on this machine.") +} + +// printForceAddHint tells the user how to commit the scaffold openly in a repo +// that should override the global default. Git never re-ignores a tracked file, +// so force-add is the documented escape hatch (gitignore negation cannot +// re-include a file under an excluded directory). +func (a App) printForceAddHint() { + _, _ = fmt.Fprintln(a.out, " To commit the scaffold openly in a specific repo, force-add it there:") + _, _ = fmt.Fprintln(a.out, " git add -f .agent AGENTS.md CLAUDE.md .devcontainer Justfile .pre-commit-config.yaml") +} + // indentBlock prefixes each line of the ignore block for the dry-run preview so // it reads as nested detail under the "ignore" line. func indentBlock(block string) string { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 1baea52..11865ed 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -224,21 +224,269 @@ func TestInitVisibilityLocalRejectedOnDocCollabFlavor(t *testing.T) { } } -func TestInitVisibilityUnimplementedModesRejected(t *testing.T) { +// All four visibility modes are now implemented, so there is no +// "unimplemented modes rejected" test. Unknown values are covered by +// TestInitVisibilityUnknownValueRejected; per-mode behavior is covered by the +// hidden and global-default tests below. + +// hiddenExcludePath is the repo-local exclude file hidden mode writes to. +func hiddenExcludePath(target string) string { + return filepath.Join(target, ".git", "info", "exclude") +} + +func TestInitVisibilityHiddenWritesGitInfoExclude(t *testing.T) { t.Parallel() - for _, mode := range []string{"hidden", "global-default"} { + target := filepath.Join(t.TempDir(), "proj") + var out bytes.Buffer + app := cli.New(&out, &bytes.Buffer{}, cli.Version{}) + + if err := app.Run(context.Background(), []string{"init", "--no-git", "--visibility=hidden", "go-cli", target}); err != nil { + t.Fatalf("Run(init --visibility=hidden go-cli) error = %v", err) + } + + exclude := hiddenExcludePath(target) + content, err := os.ReadFile(exclude) + if err != nil { + t.Fatalf("read .git/info/exclude: %v", err) + } + for _, want := range []string{"agent-init", ".agent/", "/AGENTS.md", ".devcontainer/", "/Justfile"} { + if !strings.Contains(string(content), want) { + t.Errorf(".git/info/exclude missing %q:\n%s", want, content) + } + } + // Hidden mode must leave no committed trace. The flavor ships its own + // .gitignore, so the file exists — but our block must not be in it. + if gi, err := os.ReadFile(filepath.Join(target, ".gitignore")); err == nil { + if strings.Contains(string(gi), "agent-init (private)") { + t.Errorf("hidden mode wrote the block to the committed .gitignore:\n%s", gi) + } + } + // The side effect must be announced with the absolute path. + if !strings.Contains(out.String(), exclude) { + t.Errorf("init output did not announce the exclude path %q:\n%s", exclude, out.String()) + } +} + +// TestInitPrivateAliasMatchesHidden checks --private is a true alias: it writes +// the same block to the same file as --visibility=hidden and nothing else. +func TestInitPrivateAliasMatchesHidden(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "proj") + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) + + if err := app.Run(context.Background(), []string{"init", "--no-git", "--private", "go-cli", target}); err != nil { + t.Fatalf("Run(init --private go-cli) error = %v", err) + } + + exclude := hiddenExcludePath(target) + content, err := os.ReadFile(exclude) + if err != nil { + t.Fatalf("read .git/info/exclude: %v", err) + } + if !strings.Contains(string(content), "agent-init (private)") { + t.Errorf("--private did not write the hidden block:\n%s", content) + } + if gi, err := os.ReadFile(filepath.Join(target, ".gitignore")); err == nil { + if strings.Contains(string(gi), "agent-init (private)") { + t.Errorf("--private wrote the block to the committed .gitignore:\n%s", gi) + } + } +} + +// --private and a conflicting --visibility must error rather than silently +// disagree. --private alongside --visibility=hidden is redundant but fine. +func TestInitPrivateConflictsWithVisibility(t *testing.T) { + t.Parallel() + // An explicitly-set non-hidden --visibility alongside --private is a + // conflict — including an explicit --visibility=shared, which must not be + // silently overridden by the alias. + for _, mode := range []string{"local", "shared"} { t.Run(mode, func(t *testing.T) { t.Parallel() app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) - err := app.Run(context.Background(), []string{"init", "--no-git", "--visibility=" + mode, "go-cli", t.TempDir()}) + err := app.Run(context.Background(), []string{"init", "--no-git", "--private", "--visibility=" + mode, "go-cli", t.TempDir()}) if err == nil { - t.Fatalf("Run(--visibility=%s) error = nil; want not-implemented rejection", mode) + t.Fatalf("Run(--private --visibility=%s) error = nil; want conflict rejection", mode) } - if !strings.Contains(err.Error(), "not implemented") { - t.Fatalf("error = %v; want not-implemented message", err) + if !strings.Contains(err.Error(), "conflict") { + t.Fatalf("error = %v; want conflict message", err) } }) } + + // --private alongside an explicit --visibility=hidden is redundant, not a + // conflict. + target := filepath.Join(t.TempDir(), "proj") + app2 := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) + if err := app2.Run(context.Background(), []string{"init", "--no-git", "--private", "--visibility=hidden", "go-cli", target}); err != nil { + t.Fatalf("Run(--private --visibility=hidden) error = %v; want it to be accepted as redundant", err) + } +} + +func TestInitVisibilityHiddenIsIdempotent(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "proj") + run := func() { + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) + if err := app.Run(context.Background(), []string{"init", "--no-git", "--force", "--visibility=hidden", "go-cli", target}); err != nil { + t.Fatalf("Run error = %v", err) + } + } + run() + run() + + content, err := os.ReadFile(hiddenExcludePath(target)) + if err != nil { + t.Fatalf("read .git/info/exclude: %v", err) + } + if n := strings.Count(string(content), "agent-init (private)"); n != 1 { + t.Errorf("got %d ignore blocks after re-run, want 1:\n%s", n, content) + } +} + +// isolateGlobalGitConfig points HOME, XDG_CONFIG_HOME, and GIT_CONFIG_GLOBAL at +// a temp dir so the global-default tests never read or mutate the developer's +// real machine-wide git config. GIT_CONFIG_GLOBAL is what `git config --global` +// reads/writes; pinning it to a temp file is the seam that makes shelling out +// to git safe in tests. Returns the temp HOME. +func isolateGlobalGitConfig(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(home, ".gitconfig")) + return home +} + +func TestInitVisibilityGlobalDefaultWritesExcludesAndWarns(t *testing.T) { + home := isolateGlobalGitConfig(t) + var out, errOut bytes.Buffer + app := cli.New(&out, &errOut, cli.Version{}) + + target := filepath.Join(t.TempDir(), "proj") + if err := app.Run(context.Background(), []string{"init", "--no-git", "--visibility=global-default", "go-cli", target}); err != nil { + t.Fatalf("Run(--visibility=global-default) error = %v", err) + } + + // With no core.excludesfile configured, the block lands in the default + // XDG path under the fake HOME. + excludes := filepath.Join(home, ".config", "git", "ignore") + content, err := os.ReadFile(excludes) + if err != nil { + t.Fatalf("read global excludes: %v", err) + } + if !strings.Contains(string(content), "agent-init (private)") { + t.Errorf("global excludes missing the block:\n%s", content) + } + // The edited path must be announced on stdout. + if !strings.Contains(out.String(), excludes) { + t.Errorf("did not announce the global excludes path %q:\n%s", excludes, out.String()) + } + // The machine-wide warning must be loud (on stderr). + if !strings.Contains(strings.ToUpper(errOut.String()), "MACHINE-WIDE") || !strings.Contains(errOut.String(), "EVERY") { + t.Errorf("missing machine-wide warning on stderr:\n%s", errOut.String()) + } + // The force-add escape hatch is printed. + if !strings.Contains(out.String(), "git add -f") { + t.Errorf("missing force-add hint:\n%s", out.String()) + } +} + +func TestInitVisibilityGlobalDefaultIsIdempotent(t *testing.T) { + home := isolateGlobalGitConfig(t) + run := func() { + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) + if err := app.Run(context.Background(), []string{"init", "--no-git", "--force", "--visibility=global-default", "go-cli", filepath.Join(home, "proj")}); err != nil { + t.Fatalf("Run error = %v", err) + } + } + run() + run() + + content, err := os.ReadFile(filepath.Join(home, ".config", "git", "ignore")) + if err != nil { + t.Fatalf("read global excludes: %v", err) + } + if n := strings.Count(string(content), "agent-init (private)"); n != 1 { + t.Errorf("got %d blocks after re-run, want 1:\n%s", n, content) + } +} + +func TestInitVisibilityHiddenRejectedOnDocCollabFlavor(t *testing.T) { + t.Parallel() + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) + + err := app.Run(context.Background(), []string{"init", "--no-git", "--visibility=hidden", "project-management", t.TempDir()}) + if err == nil { + t.Fatal("Run(init --visibility=hidden project-management) error = nil; want rejection") + } + if !strings.Contains(err.Error(), "visibility") { + t.Fatalf("error = %v; want to mention --visibility", err) + } +} + +func TestInitPrivateRejectedOnDocCollabFlavor(t *testing.T) { + t.Parallel() + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) + + err := app.Run(context.Background(), []string{"init", "--no-git", "--private", "project-management", t.TempDir()}) + if err == nil { + t.Fatal("Run(init --private project-management) error = nil; want rejection") + } + if !strings.Contains(err.Error(), "visibility") { + t.Fatalf("error = %v; want to mention --visibility", err) + } +} + +func TestInitVisibilityHiddenDryRunWritesNothing(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "proj") + var out bytes.Buffer + app := cli.New(&out, &bytes.Buffer{}, cli.Version{}) + + if err := app.Run(context.Background(), []string{"init", "--no-git", "--dry-run", "--private", "go-cli", target}); err != nil { + t.Fatalf("Run(--dry-run --private) error = %v", err) + } + if _, err := os.Stat(hiddenExcludePath(target)); !os.IsNotExist(err) { + t.Errorf("dry-run wrote a .git/info/exclude, stat err = %v", err) + } + if !strings.Contains(out.String(), ".agent/") { + t.Errorf("dry-run did not preview the ignore block:\n%s", out.String()) + } +} + +func TestInitVisibilityGlobalDefaultDryRunWritesNothing(t *testing.T) { + home := isolateGlobalGitConfig(t) + var out, errOut bytes.Buffer + app := cli.New(&out, &errOut, cli.Version{}) + + target := filepath.Join(t.TempDir(), "proj") + if err := app.Run(context.Background(), []string{"init", "--no-git", "--dry-run", "--visibility=global-default", "go-cli", target}); err != nil { + t.Fatalf("Run(--dry-run --visibility=global-default) error = %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".config", "git", "ignore")); !os.IsNotExist(err) { + t.Errorf("dry-run wrote the global excludes, stat err = %v", err) + } + if !strings.Contains(out.String(), ".agent/") { + t.Errorf("dry-run did not preview the ignore block:\n%s", out.String()) + } + // Even dry-run must show the warning so the user understands the scope. + if !strings.Contains(strings.ToUpper(errOut.String()), "MACHINE-WIDE") { + t.Errorf("dry-run missing machine-wide warning:\n%s", errOut.String()) + } +} + +func TestInitVisibilityGlobalDefaultRejectedOnDocCollabFlavor(t *testing.T) { + t.Parallel() + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{}) + + err := app.Run(context.Background(), []string{"init", "--no-git", "--visibility=global-default", "project-management", t.TempDir()}) + if err == nil { + t.Fatal("Run(--visibility=global-default project-management) error = nil; want rejection") + } + if !strings.Contains(err.Error(), "visibility") { + t.Fatalf("error = %v; want to mention --visibility", err) + } } func TestInitVisibilityUnknownValueRejected(t *testing.T) { diff --git a/internal/gitconfig/gitconfig.go b/internal/gitconfig/gitconfig.go new file mode 100644 index 0000000..1fa7d2b --- /dev/null +++ b/internal/gitconfig/gitconfig.go @@ -0,0 +1,166 @@ +// Package gitconfig writes the fenced agent-init ignore block to the user's +// machine-wide git excludes file (the "global-default" visibility mode, #52). +// +// This is the only place in agent-init that mutates global git configuration, +// and it does so only under the explicit `init --visibility=global-default` +// flag. The write is action-at-a-distance — it affects every repository on the +// machine — so the caller is expected to announce the edited path loudly and +// warn the user. This package keeps the global-config read/write isolated from +// the repo-local block management in internal/gitignore (which owns the block +// content via gitignore.Block); per the repo layout, the global excludes-file +// location logic lives here. +package gitconfig + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Runner runs a git subcommand and returns its trimmed stdout. It is the single +// seam through which this package touches the user's global git config, so +// tests inject a fake that records writes and never shells out. The production +// implementation is execRunner. +type Runner interface { + // Get reads a global config value. ok is false when the key is unset + // (git exits non-zero), which callers must distinguish from a hard error. + Get(key string) (value string, ok bool, err error) + // Set writes a global config value. + Set(key, value string) error +} + +// Env supplies the home directory and XDG config base used to resolve the +// default excludes path. It is injectable so tests never read the real HOME. +type Env interface { + HomeDir() (string, error) + Getenv(key string) string +} + +// upsertFunc renders the managed-block-preserving content for a file. It is the +// same upsert logic gitignore uses for repo-local files; injecting it keeps the +// block content owned by internal/gitignore while the global-file location +// logic stays here. +type upsertFunc func(existing string) string + +// EnsureGlobal writes the fenced ignore block to the user's machine-wide git +// excludes file and returns the absolute path it edited. It resolves the target +// from `git config --global core.excludesfile`; if that is unset it falls back +// to ${XDG_CONFIG_HOME:-~/.config}/git/ignore and sets core.excludesfile to that +// path (the single global-config write this mode authorizes). The block is +// upserted idempotently: a stale managed block is replaced in place. +func EnsureGlobal(runner Runner, env Env, upsert upsertFunc) (string, error) { + path, setKey, err := resolveExcludesPath(runner, env) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("creating global excludes directory: %w", err) + } + existing, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("reading %s: %w", path, err) + } + if err := os.WriteFile(path, []byte(upsert(string(existing))), 0o644); err != nil { + return "", fmt.Errorf("writing %s: %w", path, err) + } + // Only point core.excludesfile at the fallback path when it was unset. + // When the user already configured an excludes file we wrote to that one + // and must not touch any other global-config key. + if setKey { + if err := runner.Set("core.excludesfile", path); err != nil { + return "", fmt.Errorf("setting core.excludesfile: %w", err) + } + } + return path, nil +} + +// GlobalPath resolves the machine-wide excludes path without writing anything. +// It is used by the --dry-run preview so the announced path matches what +// EnsureGlobal would edit. +func GlobalPath(runner Runner, env Env) (string, error) { + path, _, err := resolveExcludesPath(runner, env) + return path, err +} + +// resolveExcludesPath returns the excludes file path and whether +// core.excludesfile needs to be set (true only on the unset-fallback path). +func resolveExcludesPath(runner Runner, env Env) (path string, setKey bool, err error) { + configured, ok, err := runner.Get("core.excludesfile") + if err != nil { + return "", false, fmt.Errorf("reading core.excludesfile: %w", err) + } + if ok && strings.TrimSpace(configured) != "" { + expanded, err := expandHome(env, strings.TrimSpace(configured)) + if err != nil { + return "", false, err + } + return expanded, false, nil + } + fallback, err := defaultExcludesPath(env) + if err != nil { + return "", false, err + } + return fallback, true, nil +} + +// defaultExcludesPath returns ${XDG_CONFIG_HOME:-~/.config}/git/ignore, the +// path git itself uses when core.excludesfile is unset. +func defaultExcludesPath(env Env) (string, error) { + if xdg := strings.TrimSpace(env.Getenv("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(xdg, "git", "ignore"), nil + } + home, err := env.HomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, ".config", "git", "ignore"), nil +} + +// expandHome resolves a leading "~" in a configured path against the env's home +// directory, matching how git expands core.excludesfile. +func expandHome(env Env, path string) (string, error) { + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := env.HomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, strings.TrimPrefix(path, "~")), nil + } + return path, nil +} + +// OSEnv is the production Env backed by the process environment. +type OSEnv struct{} + +func (OSEnv) HomeDir() (string, error) { return os.UserHomeDir() } +func (OSEnv) Getenv(key string) string { return os.Getenv(key) } + +// execRunner is the production Runner backed by the `git` binary. +type execRunner struct{} + +// NewExecRunner returns a Runner that shells out to git for global config. +func NewExecRunner() Runner { return execRunner{} } + +func (execRunner) Get(key string) (string, bool, error) { + out, err := exec.Command("git", "config", "--global", "--get", key).Output() + if err != nil { + // `git config --get` exits 1 when the key is unset; that is not a + // hard error, just an absent value. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", false, nil + } + return "", false, fmt.Errorf("running git config --get %s: %w", key, err) + } + return strings.TrimRight(string(out), "\n"), true, nil +} + +func (execRunner) Set(key, value string) error { + if err := exec.Command("git", "config", "--global", key, value).Run(); err != nil { + return fmt.Errorf("running git config --global %s: %w", key, err) + } + return nil +} diff --git a/internal/gitconfig/gitconfig_test.go b/internal/gitconfig/gitconfig_test.go new file mode 100644 index 0000000..c462416 --- /dev/null +++ b/internal/gitconfig/gitconfig_test.go @@ -0,0 +1,241 @@ +package gitconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeRunner stands in for the git binary so tests never read or mutate the +// real global config. It records the last Set call so tests can assert that +// core.excludesfile is written only on the unset-fallback path. +type fakeRunner struct { + value string // current core.excludesfile value + hasKey bool // whether the key is set + getErr error // injected hard error from Get + + setKey string + setValue string + setCalls int +} + +func (r *fakeRunner) Get(key string) (string, bool, error) { + if r.getErr != nil { + return "", false, r.getErr + } + if key == "core.excludesfile" && r.hasKey { + return r.value, true, nil + } + return "", false, nil +} + +func (r *fakeRunner) Set(key, value string) error { + r.setKey = key + r.setValue = value + r.setCalls++ + r.value = value + r.hasKey = true + return nil +} + +// fakeEnv resolves HOME and XDG_CONFIG_HOME from test-controlled values so the +// real environment is never consulted. The CLAUDE.md testing checklist for +// internal/gitconfig requires a fake HOME; this is that seam. +type fakeEnv struct { + home string + vars map[string]string +} + +func (e fakeEnv) HomeDir() (string, error) { return e.home, nil } +func (e fakeEnv) Getenv(key string) string { return e.vars[key] } + +// block is a stand-in managed block; the real one comes from gitignore.Block. +const block = "# >>> agent-init (private) >>>\n.agent/\n/Justfile\n# <<< agent-init <<<\n" + +// upsert mimics the gitignore upsert: replace an existing block in place, else +// append. Kept minimal — gitignore owns the real logic and is tested there. +func upsert(existing string) string { + const start = "# >>> agent-init (private) >>>" + const end = "# <<< agent-init <<<" + if i := strings.Index(existing, start); i >= 0 { + if j := strings.Index(existing[i:], end); j >= 0 { + tail := existing[i+j+len(end):] + tail = strings.TrimPrefix(tail, "\n") + return existing[:i] + block + tail + } + } + if existing == "" { + return block + } + sep := "\n" + if strings.HasSuffix(existing, "\n") { + sep = "" + } + return existing + sep + "\n" + block +} + +func read(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +// withFakeHome sets HOME and XDG_CONFIG_HOME to a temp dir so a stray real +// implementation (OSEnv) would also be sandboxed. The injected fakeEnv is what +// the code under test actually uses; this is belt-and-suspenders. +func withFakeHome(t *testing.T) (home string, env fakeEnv) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + return home, fakeEnv{home: home, vars: map[string]string{}} +} + +func TestEnsureGlobalCreatesDefaultPathAndSetsKey(t *testing.T) { + home, env := withFakeHome(t) + runner := &fakeRunner{} // core.excludesfile unset + + got, err := EnsureGlobal(runner, env, upsert) + if err != nil { + t.Fatalf("EnsureGlobal() error = %v", err) + } + + want := filepath.Join(home, ".config", "git", "ignore") + if got != want { + t.Errorf("path = %q, want %q", got, want) + } + if content := read(t, got); !strings.Contains(content, block) { + t.Errorf("block not written:\n%s", content) + } + // On the unset-fallback path, core.excludesfile must be set to the file. + if runner.setCalls != 1 || runner.setKey != "core.excludesfile" || runner.setValue != want { + t.Errorf("Set = (%d, %q, %q); want one set of core.excludesfile to %q", + runner.setCalls, runner.setKey, runner.setValue, want) + } +} + +func TestEnsureGlobalHonorsXDGConfigHome(t *testing.T) { + _, env := withFakeHome(t) + xdg := t.TempDir() + env.vars["XDG_CONFIG_HOME"] = xdg + runner := &fakeRunner{} + + got, err := EnsureGlobal(runner, env, upsert) + if err != nil { + t.Fatalf("EnsureGlobal() error = %v", err) + } + want := filepath.Join(xdg, "git", "ignore") + if got != want { + t.Errorf("path = %q, want %q", got, want) + } + if _, err := os.Stat(want); err != nil { + t.Errorf("excludes file not created at XDG path: %v", err) + } +} + +func TestEnsureGlobalHonorsConfiguredExcludesFileAndDoesNotSetKey(t *testing.T) { + _, env := withFakeHome(t) + custom := filepath.Join(t.TempDir(), "unusual", "place", "myexcludes") + runner := &fakeRunner{value: custom, hasKey: true} + + got, err := EnsureGlobal(runner, env, upsert) + if err != nil { + t.Fatalf("EnsureGlobal() error = %v", err) + } + if got != custom { + t.Errorf("path = %q, want configured %q", got, custom) + } + if content := read(t, custom); !strings.Contains(content, block) { + t.Errorf("block not written to configured path:\n%s", content) + } + // When core.excludesfile is already configured, no global-config key may + // be mutated. + if runner.setCalls != 0 { + t.Errorf("Set called %d times; want 0 when core.excludesfile is configured", runner.setCalls) + } +} + +func TestEnsureGlobalExpandsTildeInConfiguredPath(t *testing.T) { + home, env := withFakeHome(t) + runner := &fakeRunner{value: "~/my-global-ignore", hasKey: true} + + got, err := EnsureGlobal(runner, env, upsert) + if err != nil { + t.Fatalf("EnsureGlobal() error = %v", err) + } + want := filepath.Join(home, "my-global-ignore") + if got != want { + t.Errorf("path = %q, want %q (tilde expanded)", got, want) + } +} + +func TestEnsureGlobalAppendsToExistingFileWithoutOurBlock(t *testing.T) { + _, env := withFakeHome(t) + custom := filepath.Join(t.TempDir(), "excludes") + if err := os.WriteFile(custom, []byte("*.log\n.DS_Store\n"), 0o644); err != nil { + t.Fatalf("seed excludes: %v", err) + } + runner := &fakeRunner{value: custom, hasKey: true} + + if _, err := EnsureGlobal(runner, env, upsert); err != nil { + t.Fatalf("EnsureGlobal() error = %v", err) + } + content := read(t, custom) + for _, want := range []string{"*.log", ".DS_Store", ".agent/"} { + if !strings.Contains(content, want) { + t.Errorf("content missing %q:\n%s", want, content) + } + } + if n := strings.Count(content, "agent-init (private)"); n != 1 { + t.Errorf("got %d blocks, want 1:\n%s", n, content) + } +} + +func TestEnsureGlobalIsIdempotent(t *testing.T) { + _, env := withFakeHome(t) + custom := filepath.Join(t.TempDir(), "excludes") + if err := os.WriteFile(custom, []byte("*.log\n"), 0o644); err != nil { + t.Fatalf("seed excludes: %v", err) + } + runner := &fakeRunner{value: custom, hasKey: true} + + if _, err := EnsureGlobal(runner, env, upsert); err != nil { + t.Fatalf("first EnsureGlobal() error = %v", err) + } + first := read(t, custom) + if _, err := EnsureGlobal(runner, env, upsert); err != nil { + t.Fatalf("second EnsureGlobal() error = %v", err) + } + second := read(t, custom) + + if first != second { + t.Errorf("not idempotent:\nfirst:\n%s\nsecond:\n%s", first, second) + } + if n := strings.Count(second, "agent-init (private)"); n != 1 { + t.Errorf("re-run produced %d blocks, want 1:\n%s", n, second) + } +} + +func TestGlobalPathDoesNotWriteOrSet(t *testing.T) { + home, env := withFakeHome(t) + runner := &fakeRunner{} + + got, err := GlobalPath(runner, env) + if err != nil { + t.Fatalf("GlobalPath() error = %v", err) + } + want := filepath.Join(home, ".config", "git", "ignore") + if got != want { + t.Errorf("path = %q, want %q", got, want) + } + if _, err := os.Stat(want); !os.IsNotExist(err) { + t.Errorf("GlobalPath wrote a file, stat err = %v", err) + } + if runner.setCalls != 0 { + t.Errorf("GlobalPath set config %d times; want 0", runner.setCalls) + } +} diff --git a/internal/gitignore/gitignore.go b/internal/gitignore/gitignore.go index b120985..98db2ba 100644 --- a/internal/gitignore/gitignore.go +++ b/internal/gitignore/gitignore.go @@ -5,8 +5,8 @@ // markers so it can be found, replaced in place (idempotent re-runs), and // removed by hand to undo. The same block is written to different target files // depending on the chosen visibility mode. This package owns the block itself -// and the repo-local targets: the committed .gitignore for "local" (shipped) -// and, when it lands, .git/info/exclude for "hidden" (#53). The machine-wide +// and the repo-local targets: the committed .gitignore for "local" and the +// never-committed .git/info/exclude for "hidden". The machine-wide // "global-default" mode (#52) mutates the user's global git excludes and // belongs in internal/gitconfig/, not here, per the repo conventions. package gitignore @@ -88,6 +88,58 @@ func EnsureLocal(target string) (string, error) { return path, nil } +// HiddenPath returns the absolute path of the repo-local .git/info/exclude that +// EnsureHidden manages for the given target directory. The path is computed, not +// validated: EnsureHidden creates the .git/info parent if absent. +func HiddenPath(target string) (string, error) { + abs, err := filepath.Abs(filepath.Join(target, ".git", "info", "exclude")) + if err != nil { + return "", fmt.Errorf("resolving .git/info/exclude path: %w", err) + } + return abs, nil +} + +// EnsureHidden appends the fenced ignore block to the repo-local +// .git/info/exclude in target, creating the file (and its .git/info parent) if +// absent. Like EnsureLocal it replaces an existing block in place so re-runs +// never duplicate it. Unlike .gitignore, .git/info/exclude is never committed, +// so this leaves no tracked trace of the scaffold. It returns the absolute path +// of the file it wrote. +// +// Callers normally run this after `git init` has created the repo, so .git +// already exists. If target is not a git repo (e.g. init --private --no-git on +// a bare directory), this still creates a minimal .git/info/exclude; a later +// `git init` preserves that file rather than clobbering it, so the rule +// survives. Writing into a non-repo .git/ is harmless but is the only case +// where this materializes part of a .git/ tree. +func EnsureHidden(target string) (string, error) { + path, err := HiddenPath(target) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("creating %s: %w", filepath.Dir(path), err) + } + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("reading %s: %w", path, err) + } + updated := upsertBlock(string(existing)) + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return "", fmt.Errorf("writing %s: %w", path, err) + } + return path, nil +} + +// Upsert returns content with the managed block present exactly once, replacing +// an existing block in place or appending it. It is exported so other targets of +// the same block (e.g. the machine-wide excludes file managed by +// internal/gitconfig for the "global-default" mode) reuse the identical block +// content and idempotency rules rather than re-implementing them. +func Upsert(content string) string { + return upsertBlock(content) +} + // upsertBlock returns content with the managed block present exactly once. An // existing block (matched by markers) is replaced in place; otherwise the block // is appended, separated from prior content by a blank line. diff --git a/internal/gitignore/gitignore_test.go b/internal/gitignore/gitignore_test.go index 4e7a0f3..ff93b65 100644 --- a/internal/gitignore/gitignore_test.go +++ b/internal/gitignore/gitignore_test.go @@ -125,6 +125,123 @@ func TestEnsureLocalReplacesStaleBlockInPlace(t *testing.T) { } } +func TestEnsureHiddenCreatesAndAppends(t *testing.T) { + t.Parallel() + tests := []struct { + name string + initial string // "" means no existing exclude file + }{ + {name: "no existing file"}, + {name: "existing file without block", initial: "# git ls-files --others\nbuild/\n"}, + {name: "existing file without trailing newline", initial: "build/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, ".git", "info", "exclude") + if tt.initial != "" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir .git/info: %v", err) + } + if err := os.WriteFile(path, []byte(tt.initial), 0o644); err != nil { + t.Fatalf("seed exclude: %v", err) + } + } + + got, err := EnsureHidden(dir) + if err != nil { + t.Fatalf("EnsureHidden() error = %v", err) + } + if got != path { + t.Errorf("EnsureHidden() path = %q, want %q", got, path) + } + + content := readFile(t, path) + if tt.initial != "" && !strings.Contains(content, strings.TrimSpace(tt.initial)) { + t.Errorf("EnsureHidden dropped pre-existing content:\n%s", content) + } + if !strings.Contains(content, Block()) { + t.Errorf("EnsureHidden did not write the block:\n%s", content) + } + if strings.Count(content, blockStart) != 1 { + t.Errorf("want exactly one block, content:\n%s", content) + } + }) + } +} + +func TestEnsureHiddenIsIdempotent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, ".git", "info", "exclude") + + if _, err := EnsureHidden(dir); err != nil { + t.Fatalf("first EnsureHidden() error = %v", err) + } + first := readFile(t, path) + if _, err := EnsureHidden(dir); err != nil { + t.Fatalf("second EnsureHidden() error = %v", err) + } + second := readFile(t, path) + + if first != second { + t.Errorf("EnsureHidden not idempotent:\nfirst:\n%s\nsecond:\n%s", first, second) + } + if n := strings.Count(second, blockStart); n != 1 { + t.Errorf("idempotent re-run produced %d blocks, want 1:\n%s", n, second) + } +} + +func TestEnsureHiddenReplacesStaleBlockInPlace(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, ".git", "info", "exclude") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir .git/info: %v", err) + } + // A pre-existing block with a stale envelope entry, surrounded by user + // content, must be replaced in place — not duplicated, and the surrounding + // content preserved. + stale := "build/\n" + blockStart + "\n.agent/\n/OLD-ENTRY\n" + blockEnd + "\nscratch/\n" + if err := os.WriteFile(path, []byte(stale), 0o644); err != nil { + t.Fatalf("seed exclude: %v", err) + } + + if _, err := EnsureHidden(dir); err != nil { + t.Fatalf("EnsureHidden() error = %v", err) + } + content := readFile(t, path) + + if strings.Contains(content, "/OLD-ENTRY") { + t.Errorf("stale block entry survived:\n%s", content) + } + if n := strings.Count(content, blockStart); n != 1 { + t.Errorf("got %d blocks, want 1:\n%s", n, content) + } + for _, surrounding := range []string{"build/", "scratch/"} { + if !strings.Contains(content, surrounding) { + t.Errorf("surrounding content %q lost:\n%s", surrounding, content) + } + } + if !strings.Contains(content, "/Justfile") { + t.Errorf("refreshed block missing current envelope:\n%s", content) + } +} + +// TestEnsureHiddenWritesNoGitignore guards the core distinction from local +// mode: hidden must leave the committed .gitignore untouched. +func TestEnsureHiddenWritesNoGitignore(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if _, err := EnsureHidden(dir); err != nil { + t.Fatalf("EnsureHidden() error = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".gitignore")); !os.IsNotExist(err) { + t.Errorf("EnsureHidden touched .gitignore, stat err = %v", err) + } +} + func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path)