Skip to content

Commit f0d4e72

Browse files
committed
config: Add .git/.revupconfig and reorder precedence
Adds support for a per-checkout config at .git/.revupconfig with the highest file precedence: .git/.revupconfig > repo/.revupconfig > ~/.revupconfig. This allows users to specify custom overrides without needing to either commit or ignore the changes. It also allows us to swap ~/.revupconfig to now taking the lowest precedence. The original reason it was above repo config was because we wanted a way for the user to override the config provided by the repo. However now the config inside.git provides that, and having ~/ be lowest precedence is more similar to the functionality of other dotfiles (like gitconfig).
1 parent aab9089 commit f0d4e72

5 files changed

Lines changed: 216 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ the case where you rebase then upload and will show you a diff with upstream fil
231231

232232
Revup is highly configurable using a standard config file format. Every flag is also a config option, so users can get the exact behavior they need.
233233

234-
Flags specified on the command line take precedence, followed by config in `~/.revupconfig`, followed by `.revupconfig` in the current repo.
234+
Flags specified on the command line take precedence, followed by `.revupconfig` in the current repo's `.git` directory, followed by `.revupconfig` at the repo root, followed by `~/.revupconfig`.
235235

236236
## Repo config
237237

@@ -258,6 +258,10 @@ For example, after getting used to the workflow, a user might not need the confi
258258
skip_confirm = True
259259
```
260260

261+
## Git-dir config
262+
263+
A `.revupconfig` placed inside the current repo's `.git` directory has the highest config-file precedence and is not tracked by git. This is useful for per-checkout overrides that should not be shared with other contributors.
264+
261265
# Appendix
262266

263267
## Commit based development

‎docs/config.md‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,13 @@ revup config - Edit revup configuration files.
99
# DESCRIPTION
1010

1111
Revup stores some persistent configuration values in a python configparser
12-
compatible format. A repo specific configuration is read from the root of
13-
the current git repo in a ".revupconfig" file. A user configuration is read
14-
from REVUP_CONFIG_PATH if available, otherwise from the default path of
15-
~/.revupconfig. Any flag or argument to a revup command can
16-
be configured. Revup loads options in this order:
12+
compatible format. Any flag or argument to a revup command can be configured.
13+
Revup loads options in this order:
1714

1815
- The program has built in defaults that are given in the manual.
19-
- Repo configs take precedence over the above.
20-
- User configs take precedence over the above.
16+
- Global configs (~/.revupconfig) take precedence over the above. REVUP_CONFIG_PATH can override this path
17+
- Repo configs (.revupconfig at the repo root) take precedence over the above.
18+
- Repo-local configs (.git/.revupconfig) take precedence over the above.
2119
- Command line flags specified by the user take highest precedence.
2220

2321
# OPTIONS
@@ -36,9 +34,18 @@ revup will warn if attempting to specify them directly.
3634
**--help, -h**
3735
: Show this help page.
3836

37+
**--global, -g**
38+
: If specified, configuration value will be written to .revupconfig in the user's
39+
home directory (overridable with REVUP_CONFIG_PATH). This is the default if repo
40+
and repo-local are not specified.
41+
3942
**--repo, -r**
40-
: If specified, configuration value will be written to the file in the current
41-
repo. Otherwise, value will apply globally.
43+
: If specified, configuration value will be written to .revupconfig at the root
44+
of the current repo.
45+
46+
**--repo-local, -l**
47+
: If specified, configuration value will be written to .revupconfig inside the
48+
current repo's .git directory (common to all worktrees).
4249

4350
**--delete, -d**
4451
: Delete the value with the given flag key.

‎revup/config.py‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ def add_argument(self, *args: Any, **kwargs: Any) -> argparse.Action:
2828

2929
if no_options:
3030
neg_action = _StoreFalseAction(
31-
no_options, action.dest, action.default, False, "autogenerated negation"
31+
no_options,
32+
action.dest,
33+
action.default,
34+
False,
35+
"autogenerated negation",
3236
)
3337
neg_action.completable = ( # type: ignore[attr-defined]
3438
completable and action.default is True
@@ -110,17 +114,28 @@ class Config:
110114
# Path to config file in current repo
111115
repo_config_path: str
112116

117+
# Path to config file inside the current repo's .git directory
118+
git_dir_config_path: str
119+
113120
# Whether the config contains values that need to be flushed to the file
114121
dirty: bool = False
115122

116-
def __init__(self, config_path: str, repo_config_path: str = ""):
123+
def __init__(
124+
self,
125+
config_path: str,
126+
repo_config_path: str = "",
127+
git_dir_config_path: str = "",
128+
):
117129
self.config = configparser.ConfigParser()
118130
self.config_path = config_path
119131
self.repo_config_path = repo_config_path
132+
self.git_dir_config_path = git_dir_config_path
120133
self.file_configs: List[Tuple[str, configparser.ConfigParser]] = []
121134

122135
def read(self) -> None:
123-
for path in (self.repo_config_path, self.config_path):
136+
# Read in increasing precedence: later reads overwrite earlier ones.
137+
# Precedence: .git/.revupconfig > repo/.revupconfig > ~/.revupconfig
138+
for path in (self.config_path, self.repo_config_path, self.git_dir_config_path):
124139
if not path:
125140
continue
126141
file_conf = configparser.ConfigParser()
@@ -218,7 +233,14 @@ def config_main(conf: Config, args: argparse.Namespace, all_parsers: List[RevupA
218233
else:
219234
value = getpass.getpass(f"Input value for {command}.{key}: ").strip()
220235

221-
config_path = conf.repo_config_path if args.repo else conf.config_path
236+
if sum(bool(x) for x in (args.use_global, args.repo, args.repo_local)) > 1:
237+
raise RevupUsageException("Can only specify one of --global, --repo, or --repo-local")
238+
if args.repo_local:
239+
config_path = conf.git_dir_config_path
240+
elif args.repo:
241+
config_path = conf.repo_config_path
242+
else:
243+
config_path = conf.config_path
222244
this_config = Config(config_path)
223245
this_config.read()
224246

‎revup/revup.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,21 @@ async def get_config() -> config.Config:
9090
# There's a chicken/egg problem in getting git path from config when we need git
9191
# to find the path of the config file. Just this once, we use the default.
9292
sh = shell.Shell()
93-
repo_root = (await sh.sh(git.get_default_git(), "rev-parse", "--show-toplevel"))[1].rstrip()
94-
conf = config.Config(config_path, os.path.join(repo_root, CONFIG_FILE_NAME))
93+
rev_parse_out = (
94+
await sh.sh(
95+
git.get_default_git(),
96+
"rev-parse",
97+
"--path-format=absolute",
98+
"--show-toplevel",
99+
"--git-common-dir",
100+
)
101+
)[1].splitlines()
102+
repo_root, git_dir = rev_parse_out[0].rstrip(), rev_parse_out[1].rstrip()
103+
conf = config.Config(
104+
config_path,
105+
os.path.join(repo_root, CONFIG_FILE_NAME),
106+
os.path.join(git_dir, CONFIG_FILE_NAME),
107+
)
95108
conf.read()
96109
return conf
97110

@@ -223,7 +236,9 @@ def build_parser() -> Tuple[RevupArgParser, List[RevupArgParser]]:
223236
all_parsers
224237
)
225238
config_parser.add_argument("value", nargs="?")
239+
config_parser.add_argument("--global", "-g", action="store_true", dest="use_global")
226240
config_parser.add_argument("--repo", "-r", action="store_true")
241+
config_parser.add_argument("--repo-local", "-l", action="store_true")
227242
config_parser.add_argument("--delete", "-d", action="store_true")
228243

229244
toolkit_subparsers = toolkit_parser.add_subparsers(dest="toolkit_cmd", required=True)

‎tests/test_config.py‎

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,17 @@ def make_parsers():
2626
return [revup_parser, upload_parser]
2727

2828

29-
def make_config_args(flag, value=None, repo=False, delete=False):
30-
return argparse.Namespace(flag=[flag], value=value, repo=repo, delete=delete)
29+
def make_config_args(
30+
flag, value=None, use_global=False, repo=False, repo_local=False, delete=False
31+
):
32+
return argparse.Namespace(
33+
flag=[flag],
34+
value=value,
35+
use_global=use_global,
36+
repo=repo,
37+
repo_local=repo_local,
38+
delete=delete,
39+
)
3140

3241

3342
class TestConfigSetAndRead:
@@ -108,10 +117,66 @@ def test_repo_config_overrides_user_config(self):
108117
repo_conf.set_value("revup", "forge_url", "repo.github.com")
109118
repo_conf.write()
110119

111-
# Reading repo then user means user wins (ConfigParser last-read-wins)
112120
combined = Config(user_path, repo_config_path=repo_path)
113121
combined.read()
114-
# repo is read first, then user overrides
122+
assert combined.config.get("revup", "forge_url") == "repo.github.com"
123+
124+
def test_git_dir_config_overrides_repo_config(self):
125+
with tempfile.TemporaryDirectory() as tmp:
126+
user_path = os.path.join(tmp, "user_config")
127+
repo_path = os.path.join(tmp, "repo_config")
128+
git_dir_path = os.path.join(tmp, "git_dir_config")
129+
130+
user_conf = Config(user_path)
131+
user_conf.read()
132+
user_conf.set_value("revup", "forge_url", "user.github.com")
133+
user_conf.write()
134+
135+
repo_conf = Config(repo_path)
136+
repo_conf.read()
137+
repo_conf.set_value("revup", "forge_url", "repo.github.com")
138+
repo_conf.write()
139+
140+
git_dir_conf = Config(git_dir_path)
141+
git_dir_conf.read()
142+
git_dir_conf.set_value("revup", "forge_url", "gitdir.github.com")
143+
git_dir_conf.write()
144+
145+
combined = Config(
146+
user_path,
147+
repo_config_path=repo_path,
148+
git_dir_config_path=git_dir_path,
149+
)
150+
combined.read()
151+
assert combined.config.get("revup", "forge_url") == "gitdir.github.com"
152+
153+
def test_git_dir_config_falls_through_to_lower_layers(self):
154+
with tempfile.TemporaryDirectory() as tmp:
155+
user_path = os.path.join(tmp, "user_config")
156+
repo_path = os.path.join(tmp, "repo_config")
157+
git_dir_path = os.path.join(tmp, "git_dir_config")
158+
159+
user_conf = Config(user_path)
160+
user_conf.read()
161+
user_conf.set_value("revup", "forge_url", "user.github.com")
162+
user_conf.set_value("upload", "remote_name", "user_remote")
163+
user_conf.write()
164+
165+
repo_conf = Config(repo_path)
166+
repo_conf.read()
167+
repo_conf.set_value("upload", "remote_name", "repo_remote")
168+
repo_conf.write()
169+
170+
# git_dir config file does not exist on disk
171+
combined = Config(
172+
user_path,
173+
repo_config_path=repo_path,
174+
git_dir_config_path=git_dir_path,
175+
)
176+
combined.read()
177+
# repo overrides user
178+
assert combined.config.get("upload", "remote_name") == "repo_remote"
179+
# user value preserved when not overridden
115180
assert combined.config.get("revup", "forge_url") == "user.github.com"
116181

117182

@@ -262,6 +327,89 @@ def test_hyphen_to_underscore_normalization(self):
262327
written.read()
263328
assert written.config.get("revup", "forge_url") == "normalized.com"
264329

330+
def test_repo_local_writes_to_git_dir_config(self):
331+
with tempfile.TemporaryDirectory() as tmp:
332+
user_path = os.path.join(tmp, "user_config")
333+
repo_path = os.path.join(tmp, "repo_config")
334+
git_dir_path = os.path.join(tmp, "git_dir_config")
335+
conf = Config(user_path, repo_path, git_dir_path)
336+
conf.read()
337+
parsers = make_parsers()
338+
339+
args = make_config_args("forge_url", "local.github.com", repo_local=True)
340+
ret = config_main(conf, args, parsers)
341+
342+
assert ret == 0
343+
assert not os.path.exists(user_path)
344+
assert not os.path.exists(repo_path)
345+
written = Config(git_dir_path)
346+
written.read()
347+
assert written.config.get("revup", "forge_url") == "local.github.com"
348+
349+
def test_repo_local_with_repo_raises(self):
350+
with tempfile.TemporaryDirectory() as tmp:
351+
user_path = os.path.join(tmp, "user_config")
352+
repo_path = os.path.join(tmp, "repo_config")
353+
git_dir_path = os.path.join(tmp, "git_dir_config")
354+
conf = Config(user_path, repo_path, git_dir_path)
355+
conf.read()
356+
parsers = make_parsers()
357+
358+
args = make_config_args("forge_url", "x.github.com", repo=True, repo_local=True)
359+
with pytest.raises(RevupUsageException, match="Can only specify one"):
360+
config_main(conf, args, parsers)
361+
362+
def test_global_with_repo_raises(self):
363+
with tempfile.TemporaryDirectory() as tmp:
364+
user_path = os.path.join(tmp, "user_config")
365+
repo_path = os.path.join(tmp, "repo_config")
366+
git_dir_path = os.path.join(tmp, "git_dir_config")
367+
conf = Config(user_path, repo_path, git_dir_path)
368+
conf.read()
369+
parsers = make_parsers()
370+
371+
args = make_config_args("forge_url", "x.github.com", use_global=True, repo=True)
372+
with pytest.raises(RevupUsageException, match="Can only specify one"):
373+
config_main(conf, args, parsers)
374+
375+
def test_global_writes_to_user_config(self):
376+
with tempfile.TemporaryDirectory() as tmp:
377+
user_path = os.path.join(tmp, "user_config")
378+
repo_path = os.path.join(tmp, "repo_config")
379+
git_dir_path = os.path.join(tmp, "git_dir_config")
380+
conf = Config(user_path, repo_path, git_dir_path)
381+
conf.read()
382+
parsers = make_parsers()
383+
384+
args = make_config_args("forge_url", "global.github.com", use_global=True)
385+
ret = config_main(conf, args, parsers)
386+
387+
assert ret == 0
388+
assert not os.path.exists(repo_path)
389+
assert not os.path.exists(git_dir_path)
390+
written = Config(user_path)
391+
written.read()
392+
assert written.config.get("revup", "forge_url") == "global.github.com"
393+
394+
def test_repo_writes_to_repo_config(self):
395+
with tempfile.TemporaryDirectory() as tmp:
396+
user_path = os.path.join(tmp, "user_config")
397+
repo_path = os.path.join(tmp, "repo_config")
398+
git_dir_path = os.path.join(tmp, "git_dir_config")
399+
conf = Config(user_path, repo_path, git_dir_path)
400+
conf.read()
401+
parsers = make_parsers()
402+
403+
args = make_config_args("forge_url", "repo.github.com", repo=True)
404+
ret = config_main(conf, args, parsers)
405+
406+
assert ret == 0
407+
assert not os.path.exists(user_path)
408+
assert not os.path.exists(git_dir_path)
409+
written = Config(repo_path)
410+
written.read()
411+
assert written.config.get("revup", "forge_url") == "repo.github.com"
412+
265413

266414
class TestApplyToParsers:
267415
def test_applies_config_values_as_defaults(self):

0 commit comments

Comments
 (0)