You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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/ --yesNo config specified and no default found, will create at ~/proj/.vcspull.yamlConfig 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:
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.yaml — add 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.txt — add 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 emptyXDG_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.
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 --versionvcspull 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.
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.
Summary
There is no single answer to "which configuration file does this command use".
config.pyexposes two discovery primitives and ten subcommands assemble them by hand, so the locations searched, the file types accepted, the way-f/--fileis 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
HOMEandXDG_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.yamlforimport. No write path consults the config directory.fmt --allandmigrate --allare a third variant: the read set plus a hand-appended./.vcspull.yamland./.vcspull.json, in a block duplicated across both files.A fifth notion resolves nothing at all:
_classify_config_scope()indiscoveris the only code that readsXDG_CONFIG_DIRS, and it labels asystemscope no resolver can load.Which file types count
add,discover,fmt, andmigraterequestfiletype=["yaml"];importrequests both; readers default to both. With only~/.vcspull.jsonpresent,addcannot see the file every reader just loaded, and silently starts a second config:With both home files present,
find_home_config_files()raisesMultipleConfigWarning, which subclassesExceptiondespite its name and is caught nowhere.list,sync,status,search, andworktree listall exit 1 on an unhandled traceback:fmtandmigratesurvive the same setup by asking for YAML only, and silently ignore the JSON.How
-f/--fileis interpretedRead commands receive
pathlib.Path(args.config)constructed in the dispatcher, with no expansion and no validation. Write commands receive the string and route it throughnormalize_config_file_path(), which appliesos.path.expandvars(), thenexpanduser(), thennormpath()against the current directory. Same flag, same value, different meaning:~/proj/cfg.yaml—addexpands it;listtreats~as a literal directory name and dies withFileNotFoundError.cfg.yaml, not present —addcreates it in the current directory;listdies withFileNotFoundError.'$CFGDIR/cfg.yaml', single-quoted so the shell leaves it alone —addexpands it anyway, because vcspull runs its ownexpandvars;listdies withFileNotFoundError.cfg.txt—addaccepts it and writes YAML into it,importrefuses cleanly with✗ Unsupported config file type: .txtbefore any network call, andlistdies withNotImplementedError: .txt not supported.nodir/cfg.yaml, parent missing —addreportsError saving configwithout 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
$PWDcan resolve somewhere else entirely. Here../a.yamlexists in both the logical and the physical parent, and vcspull silently picks the physical one: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:
syncclones into whichever directory the operator was standing in. That is #562, and the assertion inexpand_dirthat fires when such a root points through a symlink is #561.Environment values are trusted structurally.
VCSPULL_CONFIGDIRnaming a directory that does not exist falls through to XDG with no notice. An emptyXDG_CONFIG_HOME— which the base directory specification says must be treated as unset — becomesPath("") / "vcspull", a relative path, so vcspull loads configuration out of the current directory:A relative
XDG_CONFIG_HOMEorVCSPULL_CONFIGDIRbehaves 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
--filesplit lives in the dispatcher: readers get a barepathlib.Pathatcli/__init__.py#L515,#L540,#L551,#L566andworktree.py#L143; writers get the string and normalize it later.Environment
Versions
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:
~,$VAR,..through a symlink — including whether vcspull performs its own variable expansion at all, and whether..is lexical or physical.~/.vcspull/both present.load_configs()already raisesLoadConfigRepoConflictfor genuine collisions, so the reporting exists and is simply unreachable whileget_config_dir()returns one directory.A single resolver pair that every subcommand calls, one for reading and one for writing, with
cwdandhomeinjectable 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:
vcspull addwrites to a config thatvcspull syncwill not read #572 is one instance of this being violated.WRITING.mdalready describe what a fatal error looks like; the resolution path does not honor them.~/.vcspull.yamlremains a supported location.Those five are the non-negotiable part. Which locations win, whether
expandvarssurvives, and where the record lives are all open.Acceptance:
--fileunder a symlinked working directory.vcspull addandvcspull importwith no--filewrite wherevcspull syncreads.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
get_config_dir,find_home_config_files,find_config_files--filenormalization applied on the write path only:normalize_config_file_pathexpand_dir,canonicalize_workspace_path,filter_by_workspaceMultipleConfigWarningis an exception, not a warning:exc.py#L10_classify_config_scope$XDG_CONFIG_HOMEis either not set or empty, a default equal to$HOME/.configshould be used"WRITING.mdvcspull addwrites to a config thatvcspull syncwill not read #572 is the read and write split observed from the outside; expand_dir assertion fails on a relative workspace root under a symlinked cwd #561 and Relative workspace roots resolve against the process cwd, not the config's directory #562 are the relative-path defects this record settles; Settings file #361 proposes the config-directory layout; Architecture / untangling #351 is the broader loader architecture