repoactive 0.4.0.dev1__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/PKG-INFO +124 -9
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/README.md +122 -7
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/pyproject.toml +1 -1
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/cli.py +173 -49
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/command.py +41 -2
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/config.py +124 -3
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/jj.py +74 -13
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/lock.py +52 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/.gitignore +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/LICENSE +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/__init__.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/boxquote.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/constants.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/generator.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/graph.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/human_commits.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/jobtree.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/__init__.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/base.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/github.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/gitlab.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/progress.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/runner.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/selection.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/settings.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/trailers.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/ui.py +0 -0
- {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/updates.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: repoactive
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Script-driven code changes with automated merge requests
|
|
5
5
|
License-Expression: GPL-3.0-or-later
|
|
6
6
|
License-File: LICENSE
|
|
@@ -68,10 +68,12 @@ request (MR) throughout; on GitHub, read that as pull request (PR).
|
|
|
68
68
|
- [Example](#example)
|
|
69
69
|
- [`[platform.<name>]`](#platformname)
|
|
70
70
|
- [Config file locations](#config-file-locations)
|
|
71
|
+
- [Reading configuration from a revset](#reading-configuration-from-a-revset)
|
|
71
72
|
- [Variables passed to job commands](#variables-passed-to-job-commands)
|
|
72
73
|
- [Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env)
|
|
73
74
|
- [Overriding values on the command line](#overriding-values-on-the-command-line)
|
|
74
75
|
- [Run control](#run-control)
|
|
76
|
+
- [Running a command ad-hoc](#running-a-command-ad-hoc)
|
|
75
77
|
- [Selecting jobs with tags](#selecting-jobs-with-tags)
|
|
76
78
|
- [Disabling jobs](#disabling-jobs)
|
|
77
79
|
- [Running a job on a schedule](#running-a-job-on-a-schedule)
|
|
@@ -274,6 +276,10 @@ plus the `Repoactive-Job` trailer that
|
|
|
274
276
|
Repoactive-Job: uv-lock-upgrade
|
|
275
277
|
```
|
|
276
278
|
|
|
279
|
+
Output above 32 KiB is truncated: the start and the end are kept and the
|
|
280
|
+
middle is replaced by a line naming how many characters went. A command that
|
|
281
|
+
_fails_ is still reported in full on the terminal.
|
|
282
|
+
|
|
277
283
|
### Keeping the local clone current
|
|
278
284
|
|
|
279
285
|
`repoactive` works entirely from the **local** repository view and never
|
|
@@ -313,11 +319,11 @@ repoactive --version
|
|
|
313
319
|
Every command that works against a repository accepts `--repo`/`-r` and
|
|
314
320
|
`--debug`/`-d`. The commands that read configuration - `run`,
|
|
315
321
|
`validate-config`, `info jobs`, and `info tags` - also accept the same
|
|
316
|
-
`--config`/`-c
|
|
317
|
-
[`repoactive run`](#repoactive-run) table below.
|
|
318
|
-
the repository alone and reads no
|
|
319
|
-
[`dump-schema`](#repoactive-dump-schema) touches no
|
|
320
|
-
takes only `--output`.
|
|
322
|
+
`--config`/`-c`, `--config-revset`, and `--set`/`-s` options, described in
|
|
323
|
+
the [`repoactive run`](#repoactive-run) table below. `--ad-hoc` is specific
|
|
324
|
+
to `run`. `recent-commits` works from the repository alone and reads no
|
|
325
|
+
configuration; [`dump-schema`](#repoactive-dump-schema) touches no
|
|
326
|
+
repository at all and takes only `--output`.
|
|
321
327
|
|
|
322
328
|
All commands exit with these status codes:
|
|
323
329
|
|
|
@@ -352,6 +358,12 @@ repoactive run --mode push
|
|
|
352
358
|
# Push branches and create or update merge requests
|
|
353
359
|
repoactive run --mode publish
|
|
354
360
|
|
|
361
|
+
# Use the configuration as it stands on trunk, whatever is checked out
|
|
362
|
+
repoactive run --config-revset 'trunk()'
|
|
363
|
+
|
|
364
|
+
# Run a one-off command without configuring a job for it
|
|
365
|
+
repoactive run --ad-hoc 'just flake-update-diff'
|
|
366
|
+
|
|
355
367
|
# Enable debug logging
|
|
356
368
|
repoactive run --debug
|
|
357
369
|
```
|
|
@@ -359,10 +371,13 @@ repoactive run --debug
|
|
|
359
371
|
| Option | Short | Description |
|
|
360
372
|
| ------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
361
373
|
| `--config PATH` | `-c` | Config file or directory of `*.toml` files; repeat to merge. Default: `.repoactive.d/` and `.repoactive.toml` under `--repo` |
|
|
374
|
+
| `--config-revset REVSET` | | Read configuration from the merged tree of `REVSET` instead of from the working copy. Cannot be combined with `--config` |
|
|
362
375
|
| `--set NAME=VALUE` | `-s` | Override a config value (repeatable); `NAME` is a dotted TOML key, `VALUE` a TOML expression. Wins over `--config` |
|
|
363
376
|
| `--repo PATH` | `-r` | jj repository path (default: `.`) |
|
|
364
377
|
| `--mode [local\|push\|publish]` | `-m` | How far to publish: `local` (default) applies only locally, `push` also pushes branches, `publish` also creates/updates MRs |
|
|
365
378
|
| `--tag TAG` | `-t` | Run jobs carrying any of these tags (repeatable). With no tags/jobs the default run targets the `enabled` tag |
|
|
379
|
+
| `--ad-hoc COMMAND` | | Run `COMMAND` as a one-off job, no configuration needed. See [Running a command ad-hoc](#running-a-command-ad-hoc) |
|
|
380
|
+
| `--ad-hoc-name NAME` | | Name the `--ad-hoc` job (and thus its branch). Default: a name derived from the command |
|
|
366
381
|
| `--debug` | `-d` | Enable debug logging |
|
|
367
382
|
|
|
368
383
|
### `repoactive recent-commits`
|
|
@@ -689,8 +704,8 @@ underscores.
|
|
|
689
704
|
- **`commit_title_prefix`** (default: inherited) - Override the commit title
|
|
690
705
|
prefix for this job only.
|
|
691
706
|
- **`output_in_commit`** (default: `true`) - Append the job's command and
|
|
692
|
-
its captured output to the commit message
|
|
693
|
-
commit message clean.
|
|
707
|
+
its captured output to the commit message (truncated in the middle above
|
|
708
|
+
32 KiB). Set to `false` to keep the commit message clean.
|
|
694
709
|
|
|
695
710
|
**Run control:**
|
|
696
711
|
|
|
@@ -831,6 +846,45 @@ directory of `*.toml` files, and may be repeated to merge several sources;
|
|
|
831
846
|
later sources win. Explicit paths are resolved relative to the current
|
|
832
847
|
directory, not `--repo`.
|
|
833
848
|
|
|
849
|
+
### Reading configuration from a revset
|
|
850
|
+
|
|
851
|
+
By default, the command reads the configuration from the working copy. The
|
|
852
|
+
working copy can contain an incomplete change or an unsuitable revision.
|
|
853
|
+
`--config-revset` creates a temporary jj workspace. The command runs
|
|
854
|
+
`jj new REVSET` in this workspace to merge the revisions. It then discovers
|
|
855
|
+
`.repoactive.d/` and `.repoactive.toml` in the temporary workspace instead
|
|
856
|
+
of under `--repo`.
|
|
857
|
+
|
|
858
|
+
```bash
|
|
859
|
+
# Always run the configuration that is on trunk, whatever is checked out
|
|
860
|
+
repoactive run --config-revset 'trunk()'
|
|
861
|
+
|
|
862
|
+
# Preview the configuration a branch would produce once it lands
|
|
863
|
+
repoactive info jobs --config-revset 'trunk() | my-feature-branch'
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
A revset that selects several revisions produces a merged tree. This
|
|
867
|
+
behavior makes the second example possible. The following rules apply:
|
|
868
|
+
|
|
869
|
+
- `--config-revset` cannot be combined with `--config`; the combination is
|
|
870
|
+
rejected. `--set`/`-s` still applies and still wins.
|
|
871
|
+
- Merging the revset's revisions can cause conflicts. `repoactive` warns and
|
|
872
|
+
names the conflicted files. The command then continues. If a configuration
|
|
873
|
+
file contains conflict markers, the command cannot parse the file and
|
|
874
|
+
reports an invalid-configuration error.
|
|
875
|
+
- The option needs jj and a colocated jj repository, even for the read-only
|
|
876
|
+
commands. A plain git repository is converted with
|
|
877
|
+
`jj git init --colocate` first - the same conversion a bare
|
|
878
|
+
`repoactive run` performs, with the same undo hint.
|
|
879
|
+
- Only the _configuration_ comes from the revset. Jobs still run on commits
|
|
880
|
+
that use `trunk()` or their `base_branch` as the base. The local
|
|
881
|
+
repository is still the source of truth. Therefore, you must still fetch
|
|
882
|
+
the clone (see
|
|
883
|
+
[Keeping the local clone current](#keeping-the-local-clone-current)).
|
|
884
|
+
|
|
885
|
+
See [ADR 0021](docs/adr/0021-read-config-from-a-revset.md) for the
|
|
886
|
+
reasoning.
|
|
887
|
+
|
|
834
888
|
### Variables passed to job commands
|
|
835
889
|
|
|
836
890
|
`repoactive` injects a few `RA_`-prefixed environment variables into every
|
|
@@ -929,6 +983,12 @@ whether the file was discovered automatically or named with `-c`. A command
|
|
|
929
983
|
whose value came from a `--set` override has no config file, so
|
|
930
984
|
`RA_CONFIG_SOURCE_DIR` is unset for it.
|
|
931
985
|
|
|
986
|
+
With [`--config-revset`](#reading-configuration-from-a-revset), the value
|
|
987
|
+
points to the temporary workspace that contains the configuration. The
|
|
988
|
+
workspace exists for the complete run. A command can read files from it. The
|
|
989
|
+
command deletes the workspace when it exits. Files written to the workspace
|
|
990
|
+
do not remain after the command exits.
|
|
991
|
+
|
|
932
992
|
### Passing secrets to commands with `secret_env`
|
|
933
993
|
|
|
934
994
|
A job command inherits `repoactive`'s environment, so a secret exported
|
|
@@ -1030,6 +1090,61 @@ The sections below cover the run-control options from the
|
|
|
1030
1090
|
[configuration reference](#jobname) in detail: which jobs a run selects, how
|
|
1031
1091
|
to schedule and throttle them, and how to generate jobs dynamically.
|
|
1032
1092
|
|
|
1093
|
+
### Running a command ad-hoc
|
|
1094
|
+
|
|
1095
|
+
`--ad-hoc COMMAND` runs a single command without writing a job for it. The
|
|
1096
|
+
repository needs no `repoactive` configuration at all:
|
|
1097
|
+
|
|
1098
|
+
```bash
|
|
1099
|
+
repoactive run --ad-hoc 'just flake-update-diff'
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
`repoactive` builds one job from the command and runs it exactly as if it
|
|
1103
|
+
had been configured and named on the command line: the command runs in a
|
|
1104
|
+
temporary workspace, its result is committed on the job's own branch, and
|
|
1105
|
+
the commit carries the usual `Repoactive-Job` trailer. `--mode push` and
|
|
1106
|
+
`--mode publish` work as they do for a configured job.
|
|
1107
|
+
|
|
1108
|
+
The job's name (and with it, its branch) is derived from the command: every
|
|
1109
|
+
run of characters a job name may not contain becomes a single dash, so
|
|
1110
|
+
`just flake-update-diff` produces the branch
|
|
1111
|
+
`repoactive/just-flake-update-diff`. The name is deterministic, so running
|
|
1112
|
+
the same ad-hoc command again updates that branch instead of opening a
|
|
1113
|
+
second one. Pass `--ad-hoc-name` for a name of your own:
|
|
1114
|
+
|
|
1115
|
+
```bash
|
|
1116
|
+
repoactive run --ad-hoc "sed -i 's/2025/2026/' LICENSE" --ad-hoc-name copyright-year
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
The commit subject names the command it ran, `Run 'just flake-update-diff'`,
|
|
1120
|
+
and carries no `commit_title_prefix`; the merge request title keeps the
|
|
1121
|
+
usual `[repoactive] ` prefix.
|
|
1122
|
+
|
|
1123
|
+
The remaining rules:
|
|
1124
|
+
|
|
1125
|
+
- Configuration is still read when it exists. `[job-defaults]` and
|
|
1126
|
+
`[platform.<name>]` apply to the ad-hoc job, and the configured jobs stay
|
|
1127
|
+
loaded.
|
|
1128
|
+
- The ad-hoc job is selected the way a job named on the command line is, so
|
|
1129
|
+
the run is the union of it, any jobs named as arguments, and any `--tag`
|
|
1130
|
+
selection (see [Selecting jobs with tags](#selecting-jobs-with-tags)). On
|
|
1131
|
+
its own, `--ad-hoc` therefore runs that one job and nothing else; combine
|
|
1132
|
+
it with `--tag weekly` and the weekly jobs run too.
|
|
1133
|
+
- `--set`/`-s` still wins over everything, so a single field can be tuned in
|
|
1134
|
+
place: `--ad-hoc 'cargo update' -s 'job.cargo-update.timeout = "10m"'`.
|
|
1135
|
+
- The name must be free. If a configured job already uses it, `repoactive`
|
|
1136
|
+
says so and stops; `--ad-hoc-name` resolves the clash.
|
|
1137
|
+
- The command belongs to no config file, so it gets no
|
|
1138
|
+
[`RA_CONFIG_SOURCE_DIR`](#variables-passed-to-job-commands), just like a
|
|
1139
|
+
command defined through `--set`.
|
|
1140
|
+
- Like a job named on the command line, the ad-hoc job runs immediately and
|
|
1141
|
+
ignores any cooldown.
|
|
1142
|
+
|
|
1143
|
+
`--ad-hoc` is meant for one-off work and for trying a command out. A command
|
|
1144
|
+
worth running repeatedly is worth a [`[job.<name>]`](#jobname) block: a
|
|
1145
|
+
readable name, a proper title, tags, and a cooldown. See
|
|
1146
|
+
[ADR 0022](docs/adr/0022-ad-hoc-commands.md) for the reasoning.
|
|
1147
|
+
|
|
1033
1148
|
### Selecting jobs with tags
|
|
1034
1149
|
|
|
1035
1150
|
Which jobs a `repoactive run` touches is decided by **tags**. Every job has
|
|
@@ -40,10 +40,12 @@ request (MR) throughout; on GitHub, read that as pull request (PR).
|
|
|
40
40
|
- [Example](#example)
|
|
41
41
|
- [`[platform.<name>]`](#platformname)
|
|
42
42
|
- [Config file locations](#config-file-locations)
|
|
43
|
+
- [Reading configuration from a revset](#reading-configuration-from-a-revset)
|
|
43
44
|
- [Variables passed to job commands](#variables-passed-to-job-commands)
|
|
44
45
|
- [Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env)
|
|
45
46
|
- [Overriding values on the command line](#overriding-values-on-the-command-line)
|
|
46
47
|
- [Run control](#run-control)
|
|
48
|
+
- [Running a command ad-hoc](#running-a-command-ad-hoc)
|
|
47
49
|
- [Selecting jobs with tags](#selecting-jobs-with-tags)
|
|
48
50
|
- [Disabling jobs](#disabling-jobs)
|
|
49
51
|
- [Running a job on a schedule](#running-a-job-on-a-schedule)
|
|
@@ -246,6 +248,10 @@ plus the `Repoactive-Job` trailer that
|
|
|
246
248
|
Repoactive-Job: uv-lock-upgrade
|
|
247
249
|
```
|
|
248
250
|
|
|
251
|
+
Output above 32 KiB is truncated: the start and the end are kept and the
|
|
252
|
+
middle is replaced by a line naming how many characters went. A command that
|
|
253
|
+
_fails_ is still reported in full on the terminal.
|
|
254
|
+
|
|
249
255
|
### Keeping the local clone current
|
|
250
256
|
|
|
251
257
|
`repoactive` works entirely from the **local** repository view and never
|
|
@@ -285,11 +291,11 @@ repoactive --version
|
|
|
285
291
|
Every command that works against a repository accepts `--repo`/`-r` and
|
|
286
292
|
`--debug`/`-d`. The commands that read configuration - `run`,
|
|
287
293
|
`validate-config`, `info jobs`, and `info tags` - also accept the same
|
|
288
|
-
`--config`/`-c
|
|
289
|
-
[`repoactive run`](#repoactive-run) table below.
|
|
290
|
-
the repository alone and reads no
|
|
291
|
-
[`dump-schema`](#repoactive-dump-schema) touches no
|
|
292
|
-
takes only `--output`.
|
|
294
|
+
`--config`/`-c`, `--config-revset`, and `--set`/`-s` options, described in
|
|
295
|
+
the [`repoactive run`](#repoactive-run) table below. `--ad-hoc` is specific
|
|
296
|
+
to `run`. `recent-commits` works from the repository alone and reads no
|
|
297
|
+
configuration; [`dump-schema`](#repoactive-dump-schema) touches no
|
|
298
|
+
repository at all and takes only `--output`.
|
|
293
299
|
|
|
294
300
|
All commands exit with these status codes:
|
|
295
301
|
|
|
@@ -324,6 +330,12 @@ repoactive run --mode push
|
|
|
324
330
|
# Push branches and create or update merge requests
|
|
325
331
|
repoactive run --mode publish
|
|
326
332
|
|
|
333
|
+
# Use the configuration as it stands on trunk, whatever is checked out
|
|
334
|
+
repoactive run --config-revset 'trunk()'
|
|
335
|
+
|
|
336
|
+
# Run a one-off command without configuring a job for it
|
|
337
|
+
repoactive run --ad-hoc 'just flake-update-diff'
|
|
338
|
+
|
|
327
339
|
# Enable debug logging
|
|
328
340
|
repoactive run --debug
|
|
329
341
|
```
|
|
@@ -331,10 +343,13 @@ repoactive run --debug
|
|
|
331
343
|
| Option | Short | Description |
|
|
332
344
|
| ------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
333
345
|
| `--config PATH` | `-c` | Config file or directory of `*.toml` files; repeat to merge. Default: `.repoactive.d/` and `.repoactive.toml` under `--repo` |
|
|
346
|
+
| `--config-revset REVSET` | | Read configuration from the merged tree of `REVSET` instead of from the working copy. Cannot be combined with `--config` |
|
|
334
347
|
| `--set NAME=VALUE` | `-s` | Override a config value (repeatable); `NAME` is a dotted TOML key, `VALUE` a TOML expression. Wins over `--config` |
|
|
335
348
|
| `--repo PATH` | `-r` | jj repository path (default: `.`) |
|
|
336
349
|
| `--mode [local\|push\|publish]` | `-m` | How far to publish: `local` (default) applies only locally, `push` also pushes branches, `publish` also creates/updates MRs |
|
|
337
350
|
| `--tag TAG` | `-t` | Run jobs carrying any of these tags (repeatable). With no tags/jobs the default run targets the `enabled` tag |
|
|
351
|
+
| `--ad-hoc COMMAND` | | Run `COMMAND` as a one-off job, no configuration needed. See [Running a command ad-hoc](#running-a-command-ad-hoc) |
|
|
352
|
+
| `--ad-hoc-name NAME` | | Name the `--ad-hoc` job (and thus its branch). Default: a name derived from the command |
|
|
338
353
|
| `--debug` | `-d` | Enable debug logging |
|
|
339
354
|
|
|
340
355
|
### `repoactive recent-commits`
|
|
@@ -661,8 +676,8 @@ underscores.
|
|
|
661
676
|
- **`commit_title_prefix`** (default: inherited) - Override the commit title
|
|
662
677
|
prefix for this job only.
|
|
663
678
|
- **`output_in_commit`** (default: `true`) - Append the job's command and
|
|
664
|
-
its captured output to the commit message
|
|
665
|
-
commit message clean.
|
|
679
|
+
its captured output to the commit message (truncated in the middle above
|
|
680
|
+
32 KiB). Set to `false` to keep the commit message clean.
|
|
666
681
|
|
|
667
682
|
**Run control:**
|
|
668
683
|
|
|
@@ -803,6 +818,45 @@ directory of `*.toml` files, and may be repeated to merge several sources;
|
|
|
803
818
|
later sources win. Explicit paths are resolved relative to the current
|
|
804
819
|
directory, not `--repo`.
|
|
805
820
|
|
|
821
|
+
### Reading configuration from a revset
|
|
822
|
+
|
|
823
|
+
By default, the command reads the configuration from the working copy. The
|
|
824
|
+
working copy can contain an incomplete change or an unsuitable revision.
|
|
825
|
+
`--config-revset` creates a temporary jj workspace. The command runs
|
|
826
|
+
`jj new REVSET` in this workspace to merge the revisions. It then discovers
|
|
827
|
+
`.repoactive.d/` and `.repoactive.toml` in the temporary workspace instead
|
|
828
|
+
of under `--repo`.
|
|
829
|
+
|
|
830
|
+
```bash
|
|
831
|
+
# Always run the configuration that is on trunk, whatever is checked out
|
|
832
|
+
repoactive run --config-revset 'trunk()'
|
|
833
|
+
|
|
834
|
+
# Preview the configuration a branch would produce once it lands
|
|
835
|
+
repoactive info jobs --config-revset 'trunk() | my-feature-branch'
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
A revset that selects several revisions produces a merged tree. This
|
|
839
|
+
behavior makes the second example possible. The following rules apply:
|
|
840
|
+
|
|
841
|
+
- `--config-revset` cannot be combined with `--config`; the combination is
|
|
842
|
+
rejected. `--set`/`-s` still applies and still wins.
|
|
843
|
+
- Merging the revset's revisions can cause conflicts. `repoactive` warns and
|
|
844
|
+
names the conflicted files. The command then continues. If a configuration
|
|
845
|
+
file contains conflict markers, the command cannot parse the file and
|
|
846
|
+
reports an invalid-configuration error.
|
|
847
|
+
- The option needs jj and a colocated jj repository, even for the read-only
|
|
848
|
+
commands. A plain git repository is converted with
|
|
849
|
+
`jj git init --colocate` first - the same conversion a bare
|
|
850
|
+
`repoactive run` performs, with the same undo hint.
|
|
851
|
+
- Only the _configuration_ comes from the revset. Jobs still run on commits
|
|
852
|
+
that use `trunk()` or their `base_branch` as the base. The local
|
|
853
|
+
repository is still the source of truth. Therefore, you must still fetch
|
|
854
|
+
the clone (see
|
|
855
|
+
[Keeping the local clone current](#keeping-the-local-clone-current)).
|
|
856
|
+
|
|
857
|
+
See [ADR 0021](docs/adr/0021-read-config-from-a-revset.md) for the
|
|
858
|
+
reasoning.
|
|
859
|
+
|
|
806
860
|
### Variables passed to job commands
|
|
807
861
|
|
|
808
862
|
`repoactive` injects a few `RA_`-prefixed environment variables into every
|
|
@@ -901,6 +955,12 @@ whether the file was discovered automatically or named with `-c`. A command
|
|
|
901
955
|
whose value came from a `--set` override has no config file, so
|
|
902
956
|
`RA_CONFIG_SOURCE_DIR` is unset for it.
|
|
903
957
|
|
|
958
|
+
With [`--config-revset`](#reading-configuration-from-a-revset), the value
|
|
959
|
+
points to the temporary workspace that contains the configuration. The
|
|
960
|
+
workspace exists for the complete run. A command can read files from it. The
|
|
961
|
+
command deletes the workspace when it exits. Files written to the workspace
|
|
962
|
+
do not remain after the command exits.
|
|
963
|
+
|
|
904
964
|
### Passing secrets to commands with `secret_env`
|
|
905
965
|
|
|
906
966
|
A job command inherits `repoactive`'s environment, so a secret exported
|
|
@@ -1002,6 +1062,61 @@ The sections below cover the run-control options from the
|
|
|
1002
1062
|
[configuration reference](#jobname) in detail: which jobs a run selects, how
|
|
1003
1063
|
to schedule and throttle them, and how to generate jobs dynamically.
|
|
1004
1064
|
|
|
1065
|
+
### Running a command ad-hoc
|
|
1066
|
+
|
|
1067
|
+
`--ad-hoc COMMAND` runs a single command without writing a job for it. The
|
|
1068
|
+
repository needs no `repoactive` configuration at all:
|
|
1069
|
+
|
|
1070
|
+
```bash
|
|
1071
|
+
repoactive run --ad-hoc 'just flake-update-diff'
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
`repoactive` builds one job from the command and runs it exactly as if it
|
|
1075
|
+
had been configured and named on the command line: the command runs in a
|
|
1076
|
+
temporary workspace, its result is committed on the job's own branch, and
|
|
1077
|
+
the commit carries the usual `Repoactive-Job` trailer. `--mode push` and
|
|
1078
|
+
`--mode publish` work as they do for a configured job.
|
|
1079
|
+
|
|
1080
|
+
The job's name (and with it, its branch) is derived from the command: every
|
|
1081
|
+
run of characters a job name may not contain becomes a single dash, so
|
|
1082
|
+
`just flake-update-diff` produces the branch
|
|
1083
|
+
`repoactive/just-flake-update-diff`. The name is deterministic, so running
|
|
1084
|
+
the same ad-hoc command again updates that branch instead of opening a
|
|
1085
|
+
second one. Pass `--ad-hoc-name` for a name of your own:
|
|
1086
|
+
|
|
1087
|
+
```bash
|
|
1088
|
+
repoactive run --ad-hoc "sed -i 's/2025/2026/' LICENSE" --ad-hoc-name copyright-year
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
The commit subject names the command it ran, `Run 'just flake-update-diff'`,
|
|
1092
|
+
and carries no `commit_title_prefix`; the merge request title keeps the
|
|
1093
|
+
usual `[repoactive] ` prefix.
|
|
1094
|
+
|
|
1095
|
+
The remaining rules:
|
|
1096
|
+
|
|
1097
|
+
- Configuration is still read when it exists. `[job-defaults]` and
|
|
1098
|
+
`[platform.<name>]` apply to the ad-hoc job, and the configured jobs stay
|
|
1099
|
+
loaded.
|
|
1100
|
+
- The ad-hoc job is selected the way a job named on the command line is, so
|
|
1101
|
+
the run is the union of it, any jobs named as arguments, and any `--tag`
|
|
1102
|
+
selection (see [Selecting jobs with tags](#selecting-jobs-with-tags)). On
|
|
1103
|
+
its own, `--ad-hoc` therefore runs that one job and nothing else; combine
|
|
1104
|
+
it with `--tag weekly` and the weekly jobs run too.
|
|
1105
|
+
- `--set`/`-s` still wins over everything, so a single field can be tuned in
|
|
1106
|
+
place: `--ad-hoc 'cargo update' -s 'job.cargo-update.timeout = "10m"'`.
|
|
1107
|
+
- The name must be free. If a configured job already uses it, `repoactive`
|
|
1108
|
+
says so and stops; `--ad-hoc-name` resolves the clash.
|
|
1109
|
+
- The command belongs to no config file, so it gets no
|
|
1110
|
+
[`RA_CONFIG_SOURCE_DIR`](#variables-passed-to-job-commands), just like a
|
|
1111
|
+
command defined through `--set`.
|
|
1112
|
+
- Like a job named on the command line, the ad-hoc job runs immediately and
|
|
1113
|
+
ignores any cooldown.
|
|
1114
|
+
|
|
1115
|
+
`--ad-hoc` is meant for one-off work and for trying a command out. A command
|
|
1116
|
+
worth running repeatedly is worth a [`[job.<name>]`](#jobname) block: a
|
|
1117
|
+
readable name, a proper title, tags, and a cooldown. See
|
|
1118
|
+
[ADR 0022](docs/adr/0022-ad-hoc-commands.md) for the reasoning.
|
|
1119
|
+
|
|
1005
1120
|
### Selecting jobs with tags
|
|
1006
1121
|
|
|
1007
1122
|
Which jobs a `repoactive run` touches is decided by **tags**. Every job has
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Command-line interface for repoactive."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
6
|
+
from collections.abc import Generator
|
|
5
7
|
from datetime import UTC, datetime
|
|
6
8
|
from enum import StrEnum
|
|
7
9
|
from importlib.metadata import version
|
|
@@ -12,9 +14,12 @@ import typer
|
|
|
12
14
|
from rich.logging import RichHandler
|
|
13
15
|
|
|
14
16
|
from repoactive.config import (
|
|
17
|
+
AdHocJob,
|
|
18
|
+
AdHocNameCollisionError,
|
|
15
19
|
Config,
|
|
16
20
|
ConfigError,
|
|
17
21
|
ConfigNotFoundError,
|
|
22
|
+
EmptyAdHocCommandError,
|
|
18
23
|
Job,
|
|
19
24
|
MissingSecretError,
|
|
20
25
|
default_config_paths,
|
|
@@ -77,6 +82,30 @@ _SetOption = Annotated[
|
|
|
77
82
|
"VALUE is a TOML expression. Repeatable; wins over --config.",
|
|
78
83
|
),
|
|
79
84
|
]
|
|
85
|
+
_ConfigRevsetOption = Annotated[
|
|
86
|
+
str | None,
|
|
87
|
+
typer.Option(
|
|
88
|
+
"--config-revset",
|
|
89
|
+
help="Read configuration from the merged tree of this revset instead of from the "
|
|
90
|
+
"working copy. Cannot be combined with --config.",
|
|
91
|
+
),
|
|
92
|
+
]
|
|
93
|
+
_AdHocOption = Annotated[
|
|
94
|
+
str | None,
|
|
95
|
+
typer.Option(
|
|
96
|
+
"--ad-hoc",
|
|
97
|
+
help="Run this shell command as a one-off job, without configuring it. The job is "
|
|
98
|
+
"added to any configuration found and selected like a job named on the command line.",
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
_AdHocNameOption = Annotated[
|
|
102
|
+
str | None,
|
|
103
|
+
typer.Option(
|
|
104
|
+
"--ad-hoc-name",
|
|
105
|
+
help="Name for the --ad-hoc job, which also names its branch. "
|
|
106
|
+
"Default: derived from the command.",
|
|
107
|
+
),
|
|
108
|
+
]
|
|
80
109
|
_RepoOption = Annotated[Path, typer.Option("--repo", "-r", help="Path to the jj repository.")]
|
|
81
110
|
_DebugOption = Annotated[bool, typer.Option("--debug", "-d", help="Enable debug logging.")]
|
|
82
111
|
|
|
@@ -113,20 +142,40 @@ class MergeStatus(StrEnum):
|
|
|
113
142
|
unmerged = "unmerged"
|
|
114
143
|
|
|
115
144
|
|
|
116
|
-
def _resolve_config(
|
|
117
|
-
|
|
118
|
-
|
|
145
|
+
def _resolve_config(
|
|
146
|
+
config_paths: list[Path] | None, repo: Path, *, required: bool = True
|
|
147
|
+
) -> list[Path]:
|
|
148
|
+
"""Use the given config paths, or discover defaults inside repo.
|
|
149
|
+
|
|
150
|
+
With required=False a missing default config yields no paths instead of an
|
|
151
|
+
error: --ad-hoc brings its own job and needs no configuration.
|
|
152
|
+
"""
|
|
153
|
+
if config_paths:
|
|
154
|
+
return config_paths
|
|
155
|
+
try:
|
|
156
|
+
return default_config_paths(repo)
|
|
157
|
+
except ConfigNotFoundError:
|
|
158
|
+
if required:
|
|
159
|
+
raise
|
|
160
|
+
return []
|
|
119
161
|
|
|
120
162
|
|
|
121
163
|
def _load_config_or_exit(
|
|
122
|
-
config_paths: list[Path] | None,
|
|
164
|
+
config_paths: list[Path] | None,
|
|
165
|
+
repo: Path,
|
|
166
|
+
overrides: list[str] | None = None,
|
|
167
|
+
ad_hoc: AdHocJob | None = None,
|
|
123
168
|
) -> Config:
|
|
124
169
|
"""Load the config, or print a clean error and exit non-zero."""
|
|
125
170
|
try:
|
|
126
|
-
|
|
171
|
+
paths = _resolve_config(config_paths, repo, required=ad_hoc is None)
|
|
172
|
+
return load_config(paths, overrides=overrides or None, ad_hoc=ad_hoc)
|
|
127
173
|
except ConfigNotFoundError as e:
|
|
128
174
|
_error(str(e))
|
|
129
175
|
raise typer.Exit(code=1) from e
|
|
176
|
+
except (EmptyAdHocCommandError, AdHocNameCollisionError) as e:
|
|
177
|
+
_error(str(e))
|
|
178
|
+
raise typer.Exit(code=1) from e
|
|
130
179
|
except ConfigError as e:
|
|
131
180
|
_error(f"invalid config {e}")
|
|
132
181
|
raise typer.Exit(code=1) from e
|
|
@@ -137,6 +186,11 @@ def _error(message: str) -> None:
|
|
|
137
186
|
typer.secho(f"Error: {message}", err=True, fg=typer.colors.RED, bold=True)
|
|
138
187
|
|
|
139
188
|
|
|
189
|
+
def _warn(message: str) -> None:
|
|
190
|
+
"""Print message to stderr as a bold yellow Warning: line."""
|
|
191
|
+
typer.secho(f"Warning: {message}", err=True, fg=typer.colors.YELLOW, bold=True)
|
|
192
|
+
|
|
193
|
+
|
|
140
194
|
def _check_jj() -> None:
|
|
141
195
|
"""Exit with a clear error unless the jj executable is on PATH."""
|
|
142
196
|
try:
|
|
@@ -177,6 +231,45 @@ def _ensure_colocated_repo(repo: Path) -> None:
|
|
|
177
231
|
raise typer.Exit(code=1) from e
|
|
178
232
|
|
|
179
233
|
|
|
234
|
+
@contextlib.contextmanager
|
|
235
|
+
def _config_root(
|
|
236
|
+
repo: Path, config_paths: list[Path] | None, config_revset: str | None
|
|
237
|
+
) -> Generator[Path]:
|
|
238
|
+
"""Yield the directory to discover configuration in.
|
|
239
|
+
|
|
240
|
+
The context lifetime keeps configuration-relative paths valid as required by
|
|
241
|
+
ADR 0021.
|
|
242
|
+
"""
|
|
243
|
+
if config_revset is None:
|
|
244
|
+
yield repo
|
|
245
|
+
return
|
|
246
|
+
if config_paths:
|
|
247
|
+
_error("--config-revset cannot be combined with --config")
|
|
248
|
+
raise typer.Exit(code=1)
|
|
249
|
+
_check_jj()
|
|
250
|
+
# Create a colocated jj repository before the workspace. See ADR 0021.
|
|
251
|
+
_ensure_colocated_repo(repo)
|
|
252
|
+
with contextlib.ExitStack() as stack:
|
|
253
|
+
try:
|
|
254
|
+
workspace = stack.enter_context(JJ(repo).revset_workspace(config_revset))
|
|
255
|
+
_warn_about_conflicts(workspace, config_revset)
|
|
256
|
+
except JJError as e:
|
|
257
|
+
# Report an invalid revset without a traceback.
|
|
258
|
+
_error(str(e))
|
|
259
|
+
raise typer.Exit(code=1) from e
|
|
260
|
+
yield workspace.cwd
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _warn_about_conflicts(workspace: JJ, config_revset: str) -> None:
|
|
264
|
+
"""Warn about conflicted files, but continue to read the configuration."""
|
|
265
|
+
if not workspace.has_conflict("@"):
|
|
266
|
+
return
|
|
267
|
+
paths = workspace.conflicted_paths()
|
|
268
|
+
_warn(f"merging revset {config_revset!r} produced file conflicts:")
|
|
269
|
+
for path in paths:
|
|
270
|
+
typer.echo(f" {path}", err=True)
|
|
271
|
+
|
|
272
|
+
|
|
180
273
|
def _version_callback(value: bool) -> None:
|
|
181
274
|
if value:
|
|
182
275
|
typer.echo(version("repoactive"))
|
|
@@ -202,9 +295,20 @@ def callback(
|
|
|
202
295
|
raise typer.Exit(code=1) from e
|
|
203
296
|
|
|
204
297
|
|
|
298
|
+
def _ad_hoc_job(command: str | None, name: str | None) -> AdHocJob | None:
|
|
299
|
+
"""Build the AdHocJob for --ad-hoc, or exit when only --ad-hoc-name was given."""
|
|
300
|
+
if command is None:
|
|
301
|
+
if name is not None:
|
|
302
|
+
_error("--ad-hoc-name requires --ad-hoc")
|
|
303
|
+
raise typer.Exit(code=1)
|
|
304
|
+
return None
|
|
305
|
+
return AdHocJob(command=command, name=name)
|
|
306
|
+
|
|
307
|
+
|
|
205
308
|
@app.command()
|
|
206
309
|
def run( # noqa: PLR0913, PLR0917
|
|
207
310
|
config_paths: _ConfigOption = None,
|
|
311
|
+
config_revset: _ConfigRevsetOption = None,
|
|
208
312
|
repo: _RepoOption = _DEFAULT_REPO,
|
|
209
313
|
mode: Annotated[
|
|
210
314
|
RunMode,
|
|
@@ -217,6 +321,8 @@ def run( # noqa: PLR0913, PLR0917
|
|
|
217
321
|
] = RunMode.local,
|
|
218
322
|
overrides: _SetOption = None,
|
|
219
323
|
debug: _DebugOption = False,
|
|
324
|
+
ad_hoc_command: _AdHocOption = None,
|
|
325
|
+
ad_hoc_name: _AdHocNameOption = None,
|
|
220
326
|
tags: Annotated[
|
|
221
327
|
list[str] | None,
|
|
222
328
|
typer.Option(
|
|
@@ -233,35 +339,42 @@ def run( # noqa: PLR0913, PLR0917
|
|
|
233
339
|
"""Apply jobs locally; pass --mode push or --mode publish to publish."""
|
|
234
340
|
_setup_logging(debug)
|
|
235
341
|
_check_jj()
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
342
|
+
ad_hoc = _ad_hoc_job(ad_hoc_command, ad_hoc_name)
|
|
343
|
+
# An ad-hoc job is requested by name, so it runs at once like any job named
|
|
344
|
+
# on the command line, alongside whatever else was requested.
|
|
345
|
+
requested_names = frozenset(jobs or []) | ({ad_hoc.job_name} if ad_hoc else frozenset())
|
|
346
|
+
# The config workspace is held for the whole run, so a job command can still
|
|
347
|
+
# reach the files RA_CONFIG_SOURCE_DIR points at (ADR 0021).
|
|
348
|
+
with _config_root(repo, config_paths, config_revset) as config_root:
|
|
349
|
+
cfg = _load_config_or_exit(config_paths, config_root, overrides, ad_hoc)
|
|
350
|
+
_ensure_colocated_repo(repo)
|
|
351
|
+
try:
|
|
352
|
+
platform = get_platform(cfg, repo) if mode is RunMode.publish else None
|
|
353
|
+
summary = run_all(
|
|
354
|
+
config=cfg,
|
|
355
|
+
repo_path=repo,
|
|
356
|
+
platform=platform,
|
|
357
|
+
requested_names=requested_names,
|
|
358
|
+
requested_tags=frozenset(tags or []),
|
|
359
|
+
mode=mode,
|
|
360
|
+
)
|
|
361
|
+
except RunLockHeldError as e:
|
|
362
|
+
_error(str(e))
|
|
363
|
+
raise typer.Exit(code=LOCK_HELD_EXIT_CODE) from e
|
|
364
|
+
except (
|
|
365
|
+
UnknownJobsError,
|
|
366
|
+
UnknownTagsError,
|
|
367
|
+
JJError,
|
|
368
|
+
MissingSecretError,
|
|
369
|
+
NoPlatformConfiguredError,
|
|
370
|
+
PlatformTokenNotSetError,
|
|
371
|
+
PlatformError,
|
|
372
|
+
) as e:
|
|
373
|
+
# Anticipated failures (a mistyped job name or tag, a job granting an
|
|
374
|
+
# unset secret, no matching platform, an unset or rejected token, a
|
|
375
|
+
# failing jj/git invocation) get a clean error line, not a traceback.
|
|
376
|
+
_error(str(e))
|
|
377
|
+
raise typer.Exit(code=1) from e
|
|
265
378
|
if not summary.ok:
|
|
266
379
|
raise typer.Exit(code=1)
|
|
267
380
|
|
|
@@ -269,6 +382,7 @@ def run( # noqa: PLR0913, PLR0917
|
|
|
269
382
|
@app.command("validate-config")
|
|
270
383
|
def validate_config(
|
|
271
384
|
config_paths: _ConfigOption = None,
|
|
385
|
+
config_revset: _ConfigRevsetOption = None,
|
|
272
386
|
repo: _RepoOption = _DEFAULT_REPO,
|
|
273
387
|
overrides: _SetOption = None,
|
|
274
388
|
debug: _DebugOption = False,
|
|
@@ -280,25 +394,32 @@ def validate_config(
|
|
|
280
394
|
code 1 on failure.
|
|
281
395
|
"""
|
|
282
396
|
_setup_logging(debug)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
397
|
+
with _config_root(repo, config_paths, config_revset) as config_root:
|
|
398
|
+
try:
|
|
399
|
+
paths = _resolve_config(config_paths, config_root)
|
|
400
|
+
files = expand_config_paths(paths)
|
|
401
|
+
if config_revset is not None:
|
|
402
|
+
# Say where the listed paths come from; they are inside a
|
|
403
|
+
# temporary workspace that is gone by the time this returns.
|
|
404
|
+
typer.echo(f"Configuration files from revset {config_revset!r}:")
|
|
405
|
+
else:
|
|
406
|
+
typer.echo("Configuration files:")
|
|
407
|
+
for file in files:
|
|
408
|
+
typer.echo(f" {file}")
|
|
409
|
+
cfg = load_config(paths, overrides=overrides or None)
|
|
410
|
+
except ConfigNotFoundError as e:
|
|
411
|
+
_error(str(e))
|
|
412
|
+
raise typer.Exit(code=1) from e
|
|
413
|
+
except ConfigError as e:
|
|
414
|
+
_error(f"invalid config {e}")
|
|
415
|
+
raise typer.Exit(code=1) from e
|
|
296
416
|
typer.echo(f"Config OK: {len(cfg.jobs)} job(s) defined.")
|
|
297
417
|
|
|
298
418
|
|
|
299
419
|
@info_app.command("jobs")
|
|
300
420
|
def info_jobs(
|
|
301
421
|
config_paths: _ConfigOption = None,
|
|
422
|
+
config_revset: _ConfigRevsetOption = None,
|
|
302
423
|
repo: _RepoOption = _DEFAULT_REPO,
|
|
303
424
|
overrides: _SetOption = None,
|
|
304
425
|
debug: _DebugOption = False,
|
|
@@ -310,13 +431,15 @@ def info_jobs(
|
|
|
310
431
|
also shows the job's title and effective tags in aligned columns.
|
|
311
432
|
"""
|
|
312
433
|
_setup_logging(debug)
|
|
313
|
-
|
|
434
|
+
with _config_root(repo, config_paths, config_revset) as config_root:
|
|
435
|
+
cfg = _load_config_or_exit(config_paths, config_root, overrides)
|
|
314
436
|
print_job_table(format_job_forest(topological_sort(cfg.jobs)))
|
|
315
437
|
|
|
316
438
|
|
|
317
439
|
@info_app.command("tags")
|
|
318
440
|
def info_tags(
|
|
319
441
|
config_paths: _ConfigOption = None,
|
|
442
|
+
config_revset: _ConfigRevsetOption = None,
|
|
320
443
|
repo: _RepoOption = _DEFAULT_REPO,
|
|
321
444
|
overrides: _SetOption = None,
|
|
322
445
|
debug: _DebugOption = False,
|
|
@@ -331,7 +454,8 @@ def info_tags(
|
|
|
331
454
|
Each line also shows the job's title and effective tags in aligned columns.
|
|
332
455
|
"""
|
|
333
456
|
_setup_logging(debug)
|
|
334
|
-
|
|
457
|
+
with _config_root(repo, config_paths, config_revset) as config_root:
|
|
458
|
+
cfg = _load_config_or_exit(config_paths, config_root, overrides)
|
|
335
459
|
jobs_by_tag: dict[str, list[Job]] = {}
|
|
336
460
|
# Sort all jobs at once: a per-tag sort would break on dependencies whose
|
|
337
461
|
# tags differ from the dependent's.
|
|
@@ -17,6 +17,11 @@ from repoactive.settings import load_settings
|
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
19
19
|
|
|
20
|
+
# How much of a command's output is kept for the commit message and the MR
|
|
21
|
+
# description. Beyond this the middle is dropped (see truncate_middle and
|
|
22
|
+
# docs/adr/0023-cap-captured-command-output.md).
|
|
23
|
+
MAX_OUTPUT_CHARS = 32 * 1024
|
|
24
|
+
|
|
20
25
|
|
|
21
26
|
@dataclass
|
|
22
27
|
class CommandResult:
|
|
@@ -24,6 +29,35 @@ class CommandResult:
|
|
|
24
29
|
elapsed: float
|
|
25
30
|
|
|
26
31
|
|
|
32
|
+
def truncate_middle(text: str, limit: int) -> str:
|
|
33
|
+
"""Drop the middle of text so the result fits in limit characters.
|
|
34
|
+
|
|
35
|
+
Keeps 30% of the remaining budget from the start and 70% from the end, with
|
|
36
|
+
a marker in between naming how much went: the first lines say what ran, the
|
|
37
|
+
last lines say how it ended, so the middle is the expendable part. Both
|
|
38
|
+
slices are cut back to a line boundary unless that would empty them, which
|
|
39
|
+
happens when the output is one enormous line.
|
|
40
|
+
"""
|
|
41
|
+
if len(text) <= limit:
|
|
42
|
+
return text
|
|
43
|
+
# Size the marker with the widest omitted count it can ever carry, so the
|
|
44
|
+
# real marker is no longer than the one budgeted for and the result stays
|
|
45
|
+
# within limit.
|
|
46
|
+
budget = max(limit - len(_omitted_marker(len(text))), 0)
|
|
47
|
+
head_len = budget * 30 // 100
|
|
48
|
+
head, tail = text[:head_len], text[len(text) - (budget - head_len) :]
|
|
49
|
+
if "\n" in head:
|
|
50
|
+
head = head[: head.rindex("\n")]
|
|
51
|
+
if "\n" in tail:
|
|
52
|
+
tail = tail[tail.index("\n") + 1 :]
|
|
53
|
+
return head + _omitted_marker(len(text) - len(head) - len(tail)) + tail
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _omitted_marker(omitted: int) -> str:
|
|
57
|
+
"""Render the line that stands in for the characters truncate_middle dropped."""
|
|
58
|
+
return f"\n[... {omitted} characters omitted ...]\n"
|
|
59
|
+
|
|
60
|
+
|
|
27
61
|
class CommandError(RuntimeError):
|
|
28
62
|
"""A job command exited non-zero.
|
|
29
63
|
|
|
@@ -161,11 +195,16 @@ def run_command(job: Job, cwd: Path, *, env: dict[str, str]) -> CommandResult:
|
|
|
161
195
|
+ (f":\n{detail}" if detail else ""),
|
|
162
196
|
elapsed=elapsed,
|
|
163
197
|
)
|
|
164
|
-
|
|
198
|
+
# Only the success path is capped: a failure is reported in full above, where
|
|
199
|
+
# the complete output is what makes it diagnosable.
|
|
200
|
+
command_result = CommandResult(
|
|
201
|
+
output=truncate_middle(detail, MAX_OUTPUT_CHARS), elapsed=elapsed
|
|
202
|
+
)
|
|
165
203
|
logger.debug(
|
|
166
|
-
"[%s] command finished in %.3fs, %d bytes output",
|
|
204
|
+
"[%s] command finished in %.3fs, %d bytes output (%d captured)",
|
|
167
205
|
job.name,
|
|
168
206
|
command_result.elapsed,
|
|
207
|
+
len(detail),
|
|
169
208
|
len(command_result.output),
|
|
170
209
|
)
|
|
171
210
|
return command_result
|
|
@@ -40,6 +40,13 @@ logger = logging.getLogger(__name__)
|
|
|
40
40
|
_DURATION_RE = re.compile(r"^(\d+)([smhdw])$")
|
|
41
41
|
_JOB_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
42
42
|
_TAG_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
43
|
+
# Job name for an --ad-hoc command built entirely of characters a name may not
|
|
44
|
+
# contain, and the longest name derived from a command (branch_prefix is added
|
|
45
|
+
# on top of it).
|
|
46
|
+
AD_HOC_NAME_FALLBACK = "ad-hoc"
|
|
47
|
+
_AD_HOC_LABEL = "--ad-hoc"
|
|
48
|
+
_AD_HOC_SLUG_RE = re.compile(r"[^A-Za-z0-9_]+")
|
|
49
|
+
_AD_HOC_NAME_MAX_LEN = 50
|
|
43
50
|
# Environment-variable name grammar for secret_env entries. The RA_ and
|
|
44
51
|
# REPOACTIVE_ prefixes are repoactive's own (ADR 0016) and are rejected.
|
|
45
52
|
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
@@ -287,6 +294,23 @@ class BranchNameIsBaseBranchError(ValueError):
|
|
|
287
294
|
)
|
|
288
295
|
|
|
289
296
|
|
|
297
|
+
class EmptyAdHocCommandError(ValueError):
|
|
298
|
+
"""Raised when --ad-hoc is given a blank command."""
|
|
299
|
+
|
|
300
|
+
def __init__(self) -> None:
|
|
301
|
+
super().__init__("--ad-hoc needs a command to run")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class AdHocNameCollisionError(ValueError):
|
|
305
|
+
"""Raised when the ad-hoc job name is already taken by a configured job."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, name: str) -> None:
|
|
308
|
+
super().__init__(
|
|
309
|
+
f"ad-hoc job {name!r} collides with a configured job of the same name; "
|
|
310
|
+
"pass --ad-hoc-name to run it under a different name"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
290
314
|
class ConfigError(Exception):
|
|
291
315
|
"""Wraps a parse or validation error with the config source it came from."""
|
|
292
316
|
|
|
@@ -894,9 +918,14 @@ class _ConfigSource:
|
|
|
894
918
|
label: str
|
|
895
919
|
data: dict
|
|
896
920
|
# Absolute directory of the file this source was read from, or None for
|
|
897
|
-
# sources without a file (built-in defaults, --set overrides).
|
|
898
|
-
# command as RA_CONFIG_SOURCE_DIR for the job whose command
|
|
921
|
+
# sources without a file (built-in defaults, --set overrides, --ad-hoc).
|
|
922
|
+
# Exposed to a command as RA_CONFIG_SOURCE_DIR for the job whose command
|
|
923
|
+
# this source sets.
|
|
899
924
|
source_dir: Path | None = None
|
|
925
|
+
# Whether the jobs this source defines must not exist yet. Set for the
|
|
926
|
+
# --ad-hoc source, whose job would otherwise silently take over a
|
|
927
|
+
# same-named configured job's tags, labels, depends_on and cooldown.
|
|
928
|
+
reject_existing_jobs: bool = False
|
|
900
929
|
|
|
901
930
|
|
|
902
931
|
def _built_in_defaults() -> list[_ConfigSource]:
|
|
@@ -931,6 +960,69 @@ def _parse_override(text: str) -> _ConfigSource:
|
|
|
931
960
|
raise ConfigError(label, e) from e
|
|
932
961
|
|
|
933
962
|
|
|
963
|
+
def ad_hoc_job_name(command: str) -> str:
|
|
964
|
+
"""Derive a job name from an --ad-hoc command.
|
|
965
|
+
|
|
966
|
+
Every run of characters a job name may not contain, dashes included,
|
|
967
|
+
collapses into a single dash, so the same command always yields the same
|
|
968
|
+
name and thus reuses its branch across runs. Falls back to a fixed name for
|
|
969
|
+
a command that has no usable character at all.
|
|
970
|
+
"""
|
|
971
|
+
slug = _AD_HOC_SLUG_RE.sub("-", command.strip()).strip("-")
|
|
972
|
+
return slug[:_AD_HOC_NAME_MAX_LEN].strip("-") or AD_HOC_NAME_FALLBACK
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _ad_hoc_title(command: str) -> str:
|
|
976
|
+
"""Commit subject for an ad-hoc command, marking a multi-line command as cut off.
|
|
977
|
+
|
|
978
|
+
Quotes the command, switching to double quotes only when that avoids
|
|
979
|
+
nesting a quote inside itself.
|
|
980
|
+
"""
|
|
981
|
+
lines = command.strip().splitlines()
|
|
982
|
+
text = f"{lines[0].strip()} ..." if len(lines) > 1 else lines[0].strip()
|
|
983
|
+
quote = '"' if "'" in text and '"' not in text else "'"
|
|
984
|
+
return f"Run {quote}{text}{quote}"
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
@dataclass(frozen=True)
|
|
988
|
+
class AdHocJob:
|
|
989
|
+
"""The job repoactive builds from --ad-hoc instead of from configuration."""
|
|
990
|
+
|
|
991
|
+
command: str
|
|
992
|
+
# Name from --ad-hoc-name; None derives one from the command.
|
|
993
|
+
name: str | None = None
|
|
994
|
+
|
|
995
|
+
@property
|
|
996
|
+
def job_name(self) -> str:
|
|
997
|
+
# Only an absent name derives one; a given name is passed through even
|
|
998
|
+
# when it is empty, so it fails job-name validation instead of being
|
|
999
|
+
# silently replaced by the derived one.
|
|
1000
|
+
return ad_hoc_job_name(self.command) if self.name is None else self.name
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _ad_hoc_source(ad_hoc: AdHocJob) -> _ConfigSource:
|
|
1004
|
+
"""Build the config source holding the ad-hoc job.
|
|
1005
|
+
|
|
1006
|
+
The table is built directly rather than through TOML, so the command needs
|
|
1007
|
+
no quoting. It carries no source_dir: like a --set-defined command, an
|
|
1008
|
+
ad-hoc command belongs to no config file and gets no RA_CONFIG_SOURCE_DIR.
|
|
1009
|
+
"""
|
|
1010
|
+
if not ad_hoc.command.strip():
|
|
1011
|
+
raise EmptyAdHocCommandError
|
|
1012
|
+
# The title already names the command, so a prefix would only repeat that
|
|
1013
|
+
# the run was not a human's; the MR title keeps its prefix.
|
|
1014
|
+
body = {
|
|
1015
|
+
"command": ad_hoc.command,
|
|
1016
|
+
"title": _ad_hoc_title(ad_hoc.command),
|
|
1017
|
+
"commit_title_prefix": "",
|
|
1018
|
+
}
|
|
1019
|
+
return _ConfigSource(
|
|
1020
|
+
_AD_HOC_LABEL,
|
|
1021
|
+
{"job": {ad_hoc.job_name: body}},
|
|
1022
|
+
reject_existing_jobs=True,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
|
|
934
1026
|
def _read_toml_file(path: Path) -> _ConfigSource:
|
|
935
1027
|
try:
|
|
936
1028
|
return _ConfigSource(
|
|
@@ -940,16 +1032,41 @@ def _read_toml_file(path: Path) -> _ConfigSource:
|
|
|
940
1032
|
raise ConfigError(str(path), e) from e
|
|
941
1033
|
|
|
942
1034
|
|
|
943
|
-
def load_config(
|
|
1035
|
+
def load_config(
|
|
1036
|
+
paths: list[Path],
|
|
1037
|
+
overrides: list[str] | None = None,
|
|
1038
|
+
ad_hoc: AdHocJob | None = None,
|
|
1039
|
+
) -> Config:
|
|
1040
|
+
"""Merge the built-in defaults, the given config paths, an ad-hoc job, and --set overrides.
|
|
1041
|
+
|
|
1042
|
+
The ad-hoc job is layered on top of the files, so it picks up job-defaults
|
|
1043
|
+
and platforms from them, and below the overrides, so --set stays the last
|
|
1044
|
+
word on every field.
|
|
1045
|
+
"""
|
|
944
1046
|
return _merge_config(
|
|
945
1047
|
itertools.chain(
|
|
946
1048
|
_built_in_defaults(),
|
|
947
1049
|
(_read_toml_file(path) for path in expand_config_paths(paths)),
|
|
1050
|
+
() if ad_hoc is None else (_ad_hoc_source(ad_hoc),),
|
|
948
1051
|
(_parse_override(text) for text in overrides or []),
|
|
949
1052
|
)
|
|
950
1053
|
)
|
|
951
1054
|
|
|
952
1055
|
|
|
1056
|
+
def _reject_existing_jobs(source: _ConfigSource, shape: ConfigShape, merged: dict) -> None:
|
|
1057
|
+
"""Reject a source that redefines a job an earlier source already defined.
|
|
1058
|
+
|
|
1059
|
+
Only the --ad-hoc source asks for this; every other source merges into an
|
|
1060
|
+
existing job by design. The error escapes _merge_config unwrapped: the
|
|
1061
|
+
collision is with the command line, not with the config being invalid.
|
|
1062
|
+
"""
|
|
1063
|
+
if not source.reject_existing_jobs:
|
|
1064
|
+
return
|
|
1065
|
+
for name in shape.job:
|
|
1066
|
+
if name in merged.get("job", {}):
|
|
1067
|
+
raise AdHocNameCollisionError(name)
|
|
1068
|
+
|
|
1069
|
+
|
|
953
1070
|
def _merge_config(sources: Iterable[_ConfigSource]) -> Config:
|
|
954
1071
|
merged = {}
|
|
955
1072
|
# Directory of the config source that last set each job's command, keyed by
|
|
@@ -962,6 +1079,10 @@ def _merge_config(sources: Iterable[_ConfigSource]) -> Config:
|
|
|
962
1079
|
# Reject odd shapes (e.g. job.foo = "hello") up front; the merge
|
|
963
1080
|
# helpers assume tables of tables and would fail cryptically.
|
|
964
1081
|
shape = ConfigShape.model_validate(data)
|
|
1082
|
+
except ValidationError as e:
|
|
1083
|
+
raise ConfigError(source.label, e) from e
|
|
1084
|
+
_reject_existing_jobs(source, shape, merged)
|
|
1085
|
+
try:
|
|
965
1086
|
for name, body in shape.job.items():
|
|
966
1087
|
if "command" in body:
|
|
967
1088
|
command_source_dir[name] = source.source_dir
|
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import enum
|
|
5
5
|
import logging
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
8
9
|
import tempfile
|
|
9
10
|
import time
|
|
11
|
+
import uuid
|
|
10
12
|
from collections.abc import Callable, Collection, Generator
|
|
11
13
|
from dataclasses import dataclass
|
|
12
14
|
from datetime import UTC, datetime
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
|
|
15
17
|
from repoactive.constants import JOB_TRAILER_KEY
|
|
18
|
+
from repoactive.lock import config_workspace_is_free, config_workspace_lock
|
|
16
19
|
|
|
17
20
|
logger = logging.getLogger(__name__)
|
|
18
21
|
|
|
@@ -27,6 +30,9 @@ class Colocation(enum.Enum):
|
|
|
27
30
|
# be recognised and reclaimed by name (see JJ.forget_stale_workspaces).
|
|
28
31
|
WORKSPACE_PREFIX = "repoactive-tmp-"
|
|
29
32
|
|
|
33
|
+
# Prefix for temporary configuration workspaces. See ADR 0021.
|
|
34
|
+
CONFIG_WORKSPACE_PREFIX = "repoactive-config-"
|
|
35
|
+
|
|
30
36
|
# The all-zeros commit_id jj reports for the virtual root commit, which has no
|
|
31
37
|
# git counterpart.
|
|
32
38
|
GIT_ROOT_COMMIT_ID = "0" * 40
|
|
@@ -48,6 +54,14 @@ def workspace_name(job_name: str) -> str:
|
|
|
48
54
|
return f"{WORKSPACE_PREFIX}{job_name}"
|
|
49
55
|
|
|
50
56
|
|
|
57
|
+
def config_workspace_name() -> str:
|
|
58
|
+
"""Build a unique name for a configuration workspace.
|
|
59
|
+
|
|
60
|
+
The random suffix prevents name conflicts between concurrent commands.
|
|
61
|
+
"""
|
|
62
|
+
return f"{CONFIG_WORKSPACE_PREFIX}{uuid.uuid4().hex}"
|
|
63
|
+
|
|
64
|
+
|
|
51
65
|
def revset_heads(revs: list[str]) -> str:
|
|
52
66
|
"""Build a revset selecting the heads of revs.
|
|
53
67
|
|
|
@@ -211,11 +225,13 @@ class JJ:
|
|
|
211
225
|
*,
|
|
212
226
|
global_args: tuple[str, ...] = (),
|
|
213
227
|
cwd: Path | None = None,
|
|
228
|
+
stdin: str | None = None,
|
|
214
229
|
) -> str:
|
|
215
230
|
"""Run program with args, raising CommandFailedError on a non-zero exit.
|
|
216
231
|
|
|
217
232
|
global_args are inserted between the program and args but kept out
|
|
218
|
-
of logs and error messages, which show only the caller's args.
|
|
233
|
+
of logs and error messages, which show only the caller's args. stdin,
|
|
234
|
+
when given, is fed to the process instead of an empty pipe.
|
|
219
235
|
"""
|
|
220
236
|
run_cwd = cwd or self.cwd
|
|
221
237
|
logger.debug("%s %s (cwd=%s)", program, " ".join(args), run_cwd)
|
|
@@ -224,6 +240,7 @@ class JJ:
|
|
|
224
240
|
result = subprocess.run(
|
|
225
241
|
[program, *global_args, *args],
|
|
226
242
|
cwd=run_cwd,
|
|
243
|
+
input=stdin,
|
|
227
244
|
capture_output=True,
|
|
228
245
|
text=True,
|
|
229
246
|
check=True,
|
|
@@ -246,8 +263,8 @@ class JJ:
|
|
|
246
263
|
)
|
|
247
264
|
return result.stdout
|
|
248
265
|
|
|
249
|
-
def _run(self, *args: str) -> str:
|
|
250
|
-
return self._exec("jj", args, global_args=("--no-pager", "--color=never"))
|
|
266
|
+
def _run(self, *args: str, stdin: str | None = None) -> str:
|
|
267
|
+
return self._exec("jj", args, global_args=("--no-pager", "--color=never"), stdin=stdin)
|
|
251
268
|
|
|
252
269
|
def op_id(self) -> str:
|
|
253
270
|
"""Return the current operation id.
|
|
@@ -385,10 +402,6 @@ class JJ:
|
|
|
385
402
|
def abandon(self) -> None:
|
|
386
403
|
self._run("abandon", "@")
|
|
387
404
|
|
|
388
|
-
def abandon_revision(self, revision: str) -> None:
|
|
389
|
-
"""Abandon a specific revision (not the working copy)."""
|
|
390
|
-
self._run("abandon", revision)
|
|
391
|
-
|
|
392
405
|
def same_content(self, rev1: str, rev2: str) -> bool:
|
|
393
406
|
"""Return True if rev1 and rev2 have identical tree contents."""
|
|
394
407
|
return not self._run("diff", "--git", "--from", rev1, "--to", rev2).strip()
|
|
@@ -416,10 +429,6 @@ class JJ:
|
|
|
416
429
|
onto_args = [arg for parent in onto for arg in ("--onto", parent)]
|
|
417
430
|
self._run("rebase", "-s", revision, *onto_args)
|
|
418
431
|
|
|
419
|
-
def describe_revision(self, revision: str, message: str) -> None:
|
|
420
|
-
"""Set the commit message of a specific revision without touching @."""
|
|
421
|
-
self._run("describe", "-r", revision, "--message", message)
|
|
422
|
-
|
|
423
432
|
def get_description(self, revision: str) -> str:
|
|
424
433
|
"""Return the commit message of a specific revision."""
|
|
425
434
|
return self._run("log", "--no-graph", "-r", revision, "-T", "description")
|
|
@@ -428,7 +437,13 @@ class JJ:
|
|
|
428
437
|
return self._run("log", "--no-graph", "-r", "@", "-T", "self.diff().stat(50)").strip()
|
|
429
438
|
|
|
430
439
|
def describe(self, message: str) -> None:
|
|
431
|
-
|
|
440
|
+
"""Set the working copy's description.
|
|
441
|
+
|
|
442
|
+
The message goes in on stdin, not as --message: a job that embeds its
|
|
443
|
+
command output in the commit can exceed the 128 KiB Linux puts on a
|
|
444
|
+
single argv entry, which fails the exec with "argument list too long".
|
|
445
|
+
"""
|
|
446
|
+
self._run("describe", "--stdin", stdin=message)
|
|
432
447
|
|
|
433
448
|
def change_id(self, revision: str = "@") -> str:
|
|
434
449
|
"""Return the short change id of revision (the working copy by default)."""
|
|
@@ -505,6 +520,15 @@ class JJ:
|
|
|
505
520
|
output = self._run("log", "--no-graph", "-r", revset, "-T", 'commit_id ++ "\\n"')
|
|
506
521
|
return [line for line in output.splitlines() if line]
|
|
507
522
|
|
|
523
|
+
def conflicted_paths(self, revision: str = "@") -> list[str]:
|
|
524
|
+
"""Return conflicted paths in revision.
|
|
525
|
+
|
|
526
|
+
Call this only when has_conflict is true because jj reports an empty
|
|
527
|
+
list as an error.
|
|
528
|
+
"""
|
|
529
|
+
output = self._run("resolve", "--list", "--revision", revision)
|
|
530
|
+
return [re.split(r"\s{2,}", line, maxsplit=1)[0] for line in output.splitlines() if line]
|
|
531
|
+
|
|
508
532
|
def has_conflict(self, revset: str) -> bool:
|
|
509
533
|
"""Return True if any commit in revset contains a materialized conflict.
|
|
510
534
|
|
|
@@ -647,7 +671,8 @@ class JJ:
|
|
|
647
671
|
A run killed before its `finally` could call workspace_forget leaves its
|
|
648
672
|
temporary workspace (named with WORKSPACE_PREFIX) registered in jj. Because
|
|
649
673
|
the prefix is fixed, every such workspace can be recognised and dropped here,
|
|
650
|
-
even for jobs that have since been renamed or removed.
|
|
674
|
+
even for jobs that have since been renamed or removed. Only the run lock
|
|
675
|
+
makes that safe, so config workspaces stay out of scope by prefix.
|
|
651
676
|
"""
|
|
652
677
|
stale = sorted(n for n in self.workspace_names() if n.startswith(WORKSPACE_PREFIX))
|
|
653
678
|
logger.debug("stale workspaces to forget: %s", stale)
|
|
@@ -690,6 +715,42 @@ class JJ:
|
|
|
690
715
|
def workspace_forget(self, name: str) -> None:
|
|
691
716
|
self._run("workspace", "forget", name)
|
|
692
717
|
|
|
718
|
+
@contextlib.contextmanager
|
|
719
|
+
def revset_workspace(self, revset: str) -> Generator["JJ"]:
|
|
720
|
+
"""Yield a temporary workspace containing the merged tree of revset.
|
|
721
|
+
|
|
722
|
+
Conflicts remain materialised for caller inspection as required by ADR
|
|
723
|
+
0021.
|
|
724
|
+
"""
|
|
725
|
+
self.forget_stale_config_workspaces()
|
|
726
|
+
name = config_workspace_name()
|
|
727
|
+
# The lock is taken before the workspace is registered, so a command
|
|
728
|
+
# reclaiming abandoned workspaces never sees this one unlocked.
|
|
729
|
+
with (
|
|
730
|
+
config_workspace_lock(self.cwd, name),
|
|
731
|
+
self.temp_workspace(name, Colocation.PLAIN) as workspace,
|
|
732
|
+
):
|
|
733
|
+
workspace.new(revset)
|
|
734
|
+
yield workspace
|
|
735
|
+
|
|
736
|
+
def forget_stale_config_workspaces(self) -> None:
|
|
737
|
+
"""Forget configuration workspaces that have no held advisory lock.
|
|
738
|
+
|
|
739
|
+
The advisory lock identifies workspaces that live commands use. See
|
|
740
|
+
ADR 0021.
|
|
741
|
+
"""
|
|
742
|
+
abandoned = sorted(
|
|
743
|
+
name
|
|
744
|
+
for name in self.workspace_names()
|
|
745
|
+
if name.startswith(CONFIG_WORKSPACE_PREFIX)
|
|
746
|
+
and config_workspace_is_free(self.cwd, name)
|
|
747
|
+
)
|
|
748
|
+
logger.debug("abandoned config workspaces to forget: %s", abandoned)
|
|
749
|
+
for name in abandoned:
|
|
750
|
+
# Another command may be reclaiming the same workspace right now.
|
|
751
|
+
with contextlib.suppress(JJError):
|
|
752
|
+
self.workspace_forget(name)
|
|
753
|
+
|
|
693
754
|
@contextlib.contextmanager
|
|
694
755
|
def temp_workspace(
|
|
695
756
|
self, name: str, colocation: Colocation = Colocation.COLOCATED
|
|
@@ -72,6 +72,58 @@ def _write_holder(fd: int) -> None:
|
|
|
72
72
|
os.write(fd, info.encode("utf-8"))
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _config_workspace_lock_path(repo_path: Path, name: str) -> Path:
|
|
76
|
+
# Keyed by workspace name, not by the workspace directory: a reclaiming
|
|
77
|
+
# command knows the name from jj workspace list, and the directory of an
|
|
78
|
+
# abandoned workspace may be gone.
|
|
79
|
+
return repo_path.resolve() / ".jj" / f"{name}.lock"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextlib.contextmanager
|
|
83
|
+
def config_workspace_lock(repo_path: Path, name: str) -> Generator[None]:
|
|
84
|
+
"""Hold the advisory lock for a configuration workspace.
|
|
85
|
+
|
|
86
|
+
The kernel releases the lock when the process ends. This behavior identifies
|
|
87
|
+
abandoned workspaces without the use of process identifiers. See ADR 0021.
|
|
88
|
+
"""
|
|
89
|
+
path = _config_workspace_lock_path(repo_path, name)
|
|
90
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o644)
|
|
91
|
+
try:
|
|
92
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
93
|
+
_write_holder(fd)
|
|
94
|
+
try:
|
|
95
|
+
yield
|
|
96
|
+
finally:
|
|
97
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
98
|
+
finally:
|
|
99
|
+
os.close(fd)
|
|
100
|
+
path.unlink(missing_ok=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def config_workspace_is_free(repo_path: Path, name: str) -> bool:
|
|
104
|
+
"""Report whether no live command holds the config workspace name.
|
|
105
|
+
|
|
106
|
+
A missing lock file counts as free: the command that made the workspace is
|
|
107
|
+
gone and removed its lock, or never had one. Callers use this only to
|
|
108
|
+
decide that a registered workspace can be forgotten, so the worst a wrong
|
|
109
|
+
'free' can do is forget a workspace nobody is using.
|
|
110
|
+
"""
|
|
111
|
+
path = _config_workspace_lock_path(repo_path, name)
|
|
112
|
+
try:
|
|
113
|
+
fd = os.open(path, os.O_RDWR)
|
|
114
|
+
except OSError:
|
|
115
|
+
return True
|
|
116
|
+
try:
|
|
117
|
+
try:
|
|
118
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
119
|
+
except OSError:
|
|
120
|
+
return False
|
|
121
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
122
|
+
return True
|
|
123
|
+
finally:
|
|
124
|
+
os.close(fd)
|
|
125
|
+
|
|
126
|
+
|
|
75
127
|
@contextlib.contextmanager
|
|
76
128
|
def run_lock(repo_path: Path) -> Generator[None]:
|
|
77
129
|
"""Hold an exclusive per-repository run lock for the duration of the block.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|