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.
Files changed (28) hide show
  1. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/PKG-INFO +124 -9
  2. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/README.md +122 -7
  3. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/pyproject.toml +1 -1
  4. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/cli.py +173 -49
  5. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/command.py +41 -2
  6. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/config.py +124 -3
  7. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/jj.py +74 -13
  8. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/lock.py +52 -0
  9. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/.gitignore +0 -0
  10. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/LICENSE +0 -0
  11. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/__init__.py +0 -0
  12. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/boxquote.py +0 -0
  13. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/constants.py +0 -0
  14. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/generator.py +0 -0
  15. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/graph.py +0 -0
  16. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/human_commits.py +0 -0
  17. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/jobtree.py +0 -0
  18. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/__init__.py +0 -0
  19. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/base.py +0 -0
  20. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/github.py +0 -0
  21. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/platforms/gitlab.py +0 -0
  22. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/progress.py +0 -0
  23. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/runner.py +0 -0
  24. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/selection.py +0 -0
  25. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/settings.py +0 -0
  26. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/trailers.py +0 -0
  27. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/ui.py +0 -0
  28. {repoactive-0.4.0.dev1 → repoactive-0.4.2}/src/repoactive/updates.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: repoactive
3
- Version: 0.4.0.dev1
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` and `--set`/`-s` options, described in the
317
- [`repoactive run`](#repoactive-run) table below. `recent-commits` works from
318
- the repository alone and reads no configuration;
319
- [`dump-schema`](#repoactive-dump-schema) touches no repository at all and
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. Set to `false` to keep the
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` and `--set`/`-s` options, described in the
289
- [`repoactive run`](#repoactive-run) table below. `recent-commits` works from
290
- the repository alone and reads no configuration;
291
- [`dump-schema`](#repoactive-dump-schema) touches no repository at all and
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. Set to `false` to keep the
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
@@ -10,7 +10,7 @@ only-include = ["src"]
10
10
 
11
11
  [project]
12
12
  name = "repoactive"
13
- version = "0.4.0.dev1"
13
+ version = "0.4.2"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.12"
@@ -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(config_paths: list[Path] | None, repo: Path) -> list[Path]:
117
- """Use the given config paths, or discover defaults inside repo."""
118
- return config_paths or default_config_paths(repo)
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, repo: Path, overrides: list[str] | None = 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
- return load_config(_resolve_config(config_paths, repo), overrides=overrides or None)
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
- cfg = _load_config_or_exit(config_paths, repo, overrides)
237
- _ensure_colocated_repo(repo)
238
- try:
239
- platform = get_platform(cfg, repo) if mode is RunMode.publish else None
240
- summary = run_all(
241
- config=cfg,
242
- repo_path=repo,
243
- platform=platform,
244
- requested_names=frozenset(jobs or []),
245
- requested_tags=frozenset(tags or []),
246
- mode=mode,
247
- )
248
- except RunLockHeldError as e:
249
- _error(str(e))
250
- raise typer.Exit(code=LOCK_HELD_EXIT_CODE) from e
251
- except (
252
- UnknownJobsError,
253
- UnknownTagsError,
254
- JJError,
255
- MissingSecretError,
256
- NoPlatformConfiguredError,
257
- PlatformTokenNotSetError,
258
- PlatformError,
259
- ) as e:
260
- # Anticipated failures (a mistyped job name or tag, a job granting an
261
- # unset secret, no matching platform, an unset or rejected token, a
262
- # failing jj/git invocation) get a clean error line, not a traceback.
263
- _error(str(e))
264
- raise typer.Exit(code=1) from e
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
- try:
284
- paths = _resolve_config(config_paths, repo)
285
- files = expand_config_paths(paths)
286
- typer.echo("Configuration files:")
287
- for file in files:
288
- typer.echo(f" {file}")
289
- cfg = load_config(paths, overrides=overrides or None)
290
- except ConfigNotFoundError as e:
291
- _error(str(e))
292
- raise typer.Exit(code=1) from e
293
- except ConfigError as e:
294
- _error(f"invalid config {e}")
295
- raise typer.Exit(code=1) from e
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
- cfg = _load_config_or_exit(config_paths, repo, overrides)
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
- cfg = _load_config_or_exit(config_paths, repo, overrides)
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
- command_result = CommandResult(output=detail, elapsed=elapsed)
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). Exposed to a
898
- # command as RA_CONFIG_SOURCE_DIR for the job whose command this source sets.
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(paths: list[Path], overrides: list[str] | None = None) -> 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
- self._run("describe", "--message", message)
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