Skip to content

Every subcommand resolves config files by its own rules #573

Description

@tony

Summary

There is no single answer to "which configuration file does this command use". config.py exposes two discovery primitives and ten subcommands assemble them by hand, so the locations searched, the file types accepted, the way -f/--file is interpreted, and the meaning of a relative path all vary by subcommand. The result is silent divergence — a file one command writes is a file another cannot see — and unhandled tracebacks where resolution fails. This asks for one decision record that states the rules, and one resolver pair that every subcommand calls.

Motivation

The behavior differs along four independent axes. Every example below was run against 1.66.0 in a sandboxed HOME and XDG_CONFIG_HOME.

Which locations are searched

get_config_dir() returns the first existing of $VCSPULL_CONFIGDIR, $XDG_CONFIG_HOME/vcspull (else ~/.config/vcspull/), ~/.vcspull/ — one directory, no merge, no notice that the others were skipped.

Commands that read (sync, list, status, search, worktree) search the config directory and home, never the current directory. Commands that write (add, discover, fmt, migrate, import) search home and then fall back to the current directory, or to ~/.vcspull.yaml for import. No write path consults the config directory. fmt --all and migrate --all are a third variant: the read set plus a hand-appended ./.vcspull.yaml and ./.vcspull.json, in a block duplicated across both files.

A fifth notion resolves nothing at all: _classify_config_scope() in discover is the only code that reads XDG_CONFIG_DIRS, and it labels a system scope no resolver can load.

Which file types count

add, discover, fmt, and migrate request filetype=["yaml"]; import requests both; readers default to both. With only ~/.vcspull.json present, add cannot see the file every reader just loaded, and silently starts a second config:

$ vcspull list
• json-repo → ~/code/json-repo
$ vcspull add https://git.example.com/t/z.git --workspace ~/code/ --yes
No config specified and no default found, will create at ~/proj/.vcspull.yaml
Config file ~/proj/.vcspull.yaml not found. A new one will be created.
✓ Successfully added 'z' (git+https://git.example.com/t/z.git) to ~/proj/.vcspull.yaml under '~/code/'.

With both home files present, find_home_config_files() raises MultipleConfigWarning, which subclasses Exception despite its name and is caught nowhere. list, sync, status, search, and worktree list all exit 1 on an unhandled traceback:

$ vcspull list
Traceback (most recent call last):
  ...
vcspull.exc.MultipleConfigWarning

fmt and migrate survive the same setup by asking for YAML only, and silently ignore the JSON.

How -f/--file is interpreted

Read commands receive pathlib.Path(args.config) constructed in the dispatcher, with no expansion and no validation. Write commands receive the string and route it through normalize_config_file_path(), which applies os.path.expandvars(), then expanduser(), then normpath() against the current directory. Same flag, same value, different meaning:

  • ~/proj/cfg.yamladd expands it; list treats ~ as a literal directory name and dies with FileNotFoundError.
  • cfg.yaml, not present — add creates it in the current directory; list dies with FileNotFoundError.
  • '$CFGDIR/cfg.yaml', single-quoted so the shell leaves it alone — add expands it anyway, because vcspull runs its own expandvars; list dies with FileNotFoundError.
  • cfg.txtadd accepts it and writes YAML into it, import refuses cleanly with ✗ Unsupported config file type: .txt before any network call, and list dies with NotImplementedError: .txt not supported.
  • nodir/cfg.yaml, parent missing — add reports Error saving config without naming the cause; no writer creates the parent.

Three extension policies and two expansion policies, for one flag.

What a relative path is relative to

The current directory is the process working directory, so a path typed against the shell's $PWD can resolve somewhere else entirely. Here ../a.yaml exists in both the logical and the physical parent, and vcspull silently picks the physical one:

$ pwd
/tmp/vcspull-demo/shallow/link
$ vcspull list -f ../a.yaml
• physical-parent → ~/code/physical-parent

Relative workspace roots inside a config resolve against the process working directory rather than the config file's own directory, so one file addressed by one absolute path describes two different checkout locations:

$ cd /tmp/vcspull-demo/here && vcspull list -f /tmp/vcspull-demo/cfgdir/rel.yaml
• relrepo → /tmp/vcspull-demo/here/checkouts/relrepo
$ cd /tmp/vcspull-demo/there && vcspull list -f /tmp/vcspull-demo/cfgdir/rel.yaml
• relrepo → /tmp/vcspull-demo/there/checkouts/relrepo

sync clones into whichever directory the operator was standing in. That is #562, and the assertion in expand_dir that fires when such a root points through a symlink is #561.

Environment values are trusted structurally. VCSPULL_CONFIGDIR naming a directory that does not exist falls through to XDG with no notice. An empty XDG_CONFIG_HOME — which the base directory specification says must be treated as unset — becomes Path("") / "vcspull", a relative path, so vcspull loads configuration out of the current directory:

$ cd /tmp/vcspull-demo/anydir && XDG_CONFIG_HOME= vcspull list
• from-cwd → ~/code/from-cwd

A relative XDG_CONFIG_HOME or VCSPULL_CONFIGDIR behaves the same way.

Where each subcommand resolves its config

Read paths, all calling find_config_files(include_home=True): sync.py#L1483, list.py#L115, status.py#L372, search.py#L662, worktree.py#L147.

Write paths, all calling find_home_config_files(): add.py#L521, discover.py#L406, fmt.py#L578, migrate.py#L296, import_cmd/_common.py#L576.

Bulk paths adding the current directory by hand: fmt.py#L503-L513, migrate.py#L231-L239.

The --file split lives in the dispatcher: readers get a bare pathlib.Path at cli/__init__.py#L515, #L540, #L551, #L566 and worktree.py#L143; writers get the string and normalize it later.

Environment

Versions
$ vcspull --version
vcspull 1.66.0, libvcs 0.44.0

Python 3.14.6, Linux.

Proposal

Two deliverables, in order.

A decision record stating what vcspull's configuration resolution is, so the answer lives in one reviewable place rather than being inferred from ten call sites. The project keeps no decision records today, so this establishes the form as well as the content. It needs to settle, at minimum:

  • Path source — explicit flag, environment, discovery — and the relationship between the set a command writes to and the set commands read from.
  • Path form — absolute, relative, bare name, ~, $VAR, .. through a symlink — including whether vcspull performs its own variable expansion at all, and whether .. is lexical or physical.
  • Existence and validity — missing file, missing parent, unsupported extension, a directory where a file was named.
  • Multiplicity — YAML and JSON side by side, several files in the config directory, a config directory and the legacy ~/.vcspull/ both present. load_configs() already raises LoadConfigRepoConflict for genuine collisions, so the reporting exists and is simply unreachable while get_config_dir() returns one directory.
  • Environment hygiene — unset, valid, set-but-missing, empty, relative. The base directory specification settles two of those five on its own.

A single resolver pair that every subcommand calls, one for reading and one for writing, with cwd and home injectable the way the workspace-path helpers already do it. expand_dir(), canonicalize_workspace_path(), workspace_root_label(), and _workspaces.filter_by_workspace() are the existing precedent: the same problem on the workspace axis was solved by extracting shared helpers, and the config-file axis never got the same treatment.

Load-bearing constraints:

  • One rule set, applied by every subcommand. A behavior that depends on which subcommand is running is a defect against this.
  • The file a write command targets is a file the read commands load. vcspull add writes to a config that vcspull sync will not read #572 is one instance of this being violated.
  • A resolution failure produces a message, not a traceback. The exit statuses in WRITING.md already describe what a fatal error looks like; the resolution path does not honor them.
  • Nothing an existing user relies on stops working without a deprecation path. ~/.vcspull.yaml remains a supported location.
  • The rules are stated once, in the record, and the code implements them rather than re-deriving them.

Those five are the non-negotiable part. Which locations win, whether expandvars survives, and where the record lives are all open.

Acceptance:

  • The record answers each of the five areas above, and a reader can predict which file any subcommand touches without reading the source.
  • A single pair of functions performs resolution, and no subcommand computes a config path independently of them.
  • Every permutation the record defines has a test, including the empty and relative environment values, the two-files-in-home case, and a relative --file under a symlinked working directory.
  • No configuration input produces a traceback; unsupported, ambiguous, and missing all produce a message naming the path and the reason.
  • vcspull add and vcspull import with no --file write where vcspull sync reads.

Not doing: changing the configuration file format or schema, which #360 and #376 cover; relocating anyone's existing files; and the broader loader and backend architecture in #351, though a resolver pair is a step toward it rather than away from it.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorinternal code change

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions