repoactive 0.2.8.dev2__tar.gz → 0.3.0__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 (26) hide show
  1. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/PKG-INFO +123 -11
  2. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/README.md +122 -10
  3. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/pyproject.toml +1 -1
  4. repoactive-0.3.0/src/repoactive/boxquote.py +51 -0
  5. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/config.py +91 -5
  6. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/runner.py +71 -6
  7. repoactive-0.2.8.dev2/src/repoactive/boxquote.py +0 -32
  8. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/.gitignore +0 -0
  9. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/LICENSE +0 -0
  10. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/__init__.py +0 -0
  11. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/cli.py +0 -0
  12. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/constants.py +0 -0
  13. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/graph.py +0 -0
  14. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/jj.py +0 -0
  15. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/jobtree.py +0 -0
  16. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/lock.py +0 -0
  17. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/platforms/__init__.py +0 -0
  18. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/platforms/base.py +0 -0
  19. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/platforms/github.py +0 -0
  20. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/platforms/gitlab.py +0 -0
  21. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/progress.py +0 -0
  22. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/selection.py +0 -0
  23. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/settings.py +0 -0
  24. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/trailers.py +0 -0
  25. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/ui.py +0 -0
  26. {repoactive-0.2.8.dev2 → repoactive-0.3.0}/src/repoactive/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repoactive
3
- Version: 0.2.8.dev2
3
+ Version: 0.3.0
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
@@ -55,6 +55,7 @@ and (with `--mode publish`) the full MR lifecycle.
55
55
  - [Example](#example)
56
56
  - [`[platform.<name>]`](#platformname)
57
57
  - [Config file locations](#config-file-locations)
58
+ - [Variables passed to job commands](#variables-passed-to-job-commands)
58
59
  - [Overriding values on the command line](#overriding-values-on-the-command-line)
59
60
  - [Selecting jobs with tags](#selecting-jobs-with-tags)
60
61
  - [Keeping unmerged branches current](#keeping-unmerged-branches-current)
@@ -467,13 +468,17 @@ block.
467
468
  **Branch and commit options:**
468
469
 
469
470
  - **`branch_prefix`** (default: `"repoactive/"`) - Prefix prepended to the
470
- job name to form the branch name.
471
+ job name to form the branch name. Set to `""` to use the job name alone.
472
+ With an empty prefix the branch name is exactly the job name, so make sure
473
+ no job is named after your base branch (e.g. a job named `main`) - the two
474
+ branches would otherwise collide.
471
475
  - **`mr_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended to
472
476
  every MR/PR title. Set to `""` to disable.
473
477
  - **`commit_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended
474
478
  to every commit title. Set to `""` to disable.
475
479
  - **`base_branch`** (default: repo default) - Target branch for all jobs
476
- that do not set their own.
480
+ that do not set their own. May also be a jj revset expression such as
481
+ `trunk()`, `root()`, or a user-defined revset alias.
477
482
 
478
483
  **Run control:**
479
484
 
@@ -484,6 +489,10 @@ block.
484
489
  - **`timeout`** (default: `"2m"`) - Maximum runtime for a job's command.
485
490
  Same format as `cooldown_period`. See
486
491
  [Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
492
+ - **`shell`** (default: `/bin/sh`) - Interpreter used to run job commands. A
493
+ bare name is resolved on `PATH` (e.g. `"bash"`); an absolute path also
494
+ works. The command runs as `<shell> -c <command>`. Arguments are not
495
+ allowed - the value must be a single binary.
487
496
 
488
497
  ### `[job.<name>]`
489
498
 
@@ -518,7 +527,8 @@ underscores.
518
527
  **Branch and commit options:**
519
528
 
520
529
  - **`base_branch`** (default: inherited) - Target branch for this job's
521
- MR/PR.
530
+ MR/PR. May also be a jj revset expression such as `trunk()`, `root()`, or
531
+ a user-defined revset alias.
522
532
  - **`branch_prefix`** (default: inherited) - Override the branch-name prefix
523
533
  for this job only.
524
534
  - **`mr_title_prefix`** (default: inherited) - Override the MR/PR title
@@ -553,9 +563,12 @@ underscores.
553
563
  - **`timeout`** (default: inherited) - Maximum runtime for this job's
554
564
  command. Set to `"0s"` to disable the timeout entirely. See
555
565
  [Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
566
+ - **`shell`** (default: inherited) - Interpreter used to run this job's
567
+ command, overriding `job-defaults.shell`. See
568
+ [`job-defaults.shell`](#job-defaults).
556
569
  - **`emits_jobs`** (default: `false`) - Generator job: the command writes
557
- `*.toml` job fragments into `$REPOACTIVE_JOBS_DIR` instead of producing a
558
- diff. See [Generating jobs dynamically](#generating-jobs-dynamically).
570
+ `*.toml` job fragments into `$RA_JOBS_DIR` instead of producing a diff.
571
+ See [Generating jobs dynamically](#generating-jobs-dynamically).
559
572
 
560
573
  ### Stacking MRs
561
574
 
@@ -658,6 +671,105 @@ directory of `*.toml` files, and may be repeated to merge several sources;
658
671
  later sources win. Explicit paths are resolved relative to the current
659
672
  directory, not `--repo`.
660
673
 
674
+ ### Variables passed to job commands
675
+
676
+ `repoactive` injects a few `RA_`-prefixed environment variables into every
677
+ job command's environment. These are distinct from the `REPOACTIVE_`
678
+ variables [above](#environment-variables), which configure `repoactive`
679
+ itself.
680
+
681
+ | Variable | Value | Set when |
682
+ | ---------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
683
+ | `RA_JOB_NAME` | The name of the job the command belongs to. | Always. |
684
+ | `RA_WORKSPACE_DIR` | The temporary jj workspace created for the job (also the command's working directory). | Always. |
685
+ | `RA_JOB_BRANCH` | The bookmark/branch repoactive uses for the job's output. | Always. |
686
+ | `RA_JOB_BASE_BRANCH` | The branch the job's MR targets (`base_branch`, or `trunk()` by default). | Always. |
687
+ | `RA_CONFIG_SOURCE_DIR` | The directory of the config file that defined the command. | The command came from a config file (not a `--set` override or a built-in default). |
688
+ | `RA_JOBS_DIR` | The directory a generator writes its `*.toml` job fragments into. | The command is a [generator](#generating-jobs-dynamically) (`emits_jobs`). |
689
+
690
+ #### `RA_JOB_NAME`
691
+
692
+ `RA_JOB_NAME` holds the name of the job the command belongs to. A command
693
+ shared by several jobs (for instance one set in `[job-defaults]` or emitted
694
+ by a generator) can read it to tell which job is running:
695
+
696
+ ```toml
697
+ [job.lint]
698
+ command = "./run-check.sh $RA_JOB_NAME"
699
+ title = "run the shared check script for this job"
700
+ ```
701
+
702
+ #### `RA_WORKSPACE_DIR`
703
+
704
+ A job's command runs in a temporary jj workspace, which is also its working
705
+ directory. `RA_WORKSPACE_DIR` names that directory explicitly, so a command
706
+ that changes directory can still find its way back:
707
+
708
+ ```toml
709
+ [job.build]
710
+ command = "cd subdir && make && cp result $RA_WORKSPACE_DIR/subdir/"
711
+ title = "build in a subdirectory"
712
+ ```
713
+
714
+ #### `RA_JOB_BRANCH`
715
+
716
+ `RA_JOB_BRANCH` holds the bookmark (git branch) repoactive uses for the
717
+ job's output, e.g. `repoactive/uv-lock-upgrade`. The command runs on a fresh
718
+ commit while that bookmark still points at the **previous** run's commit, so
719
+ a command can inspect what it produced last time - for example to build on
720
+ it or diff against it:
721
+
722
+ ```toml
723
+ [job.notes]
724
+ command = "jj diff --from $RA_JOB_BRANCH --to @ > /tmp/delta || true; ./make-notes.sh"
725
+ title = "regenerate release notes from what changed since last run"
726
+ ```
727
+
728
+ The bookmark may not exist yet: a job's first run, a run that produced no
729
+ diff, and a generator all leave it unset on the remote, so a command that
730
+ reads it should tolerate a missing ref.
731
+
732
+ #### `RA_JOB_BASE_BRANCH`
733
+
734
+ `RA_JOB_BASE_BRANCH` holds the branch the job's merge request targets - its
735
+ `base_branch`, or `trunk()` when that is unset. A command can use it to look
736
+ at only what changed relative to the target, for instance to lint or
737
+ reformat just the affected files:
738
+
739
+ ```toml
740
+ [job.format]
741
+ command = "treefmt $(git diff --name-only $RA_JOB_BASE_BRANCH)"
742
+ title = "format files changed since the base branch"
743
+ ```
744
+
745
+ This is the branch you _configured_ as the base. When a job stacks on
746
+ another via `depends_on`, its commit actually sits on top of that other
747
+ job's output, so `RA_JOB_BASE_BRANCH` names the ultimate target of the
748
+ stack, not the immediate parent.
749
+
750
+ #### `RA_CONFIG_SOURCE_DIR`
751
+
752
+ Because the command runs in that throwaway workspace rather than in the
753
+ directory its config lives in, a relative path cannot reach a helper file
754
+ kept beside the config. `RA_CONFIG_SOURCE_DIR` bridges that: it holds the
755
+ directory of the config file that defined the job's command:
756
+
757
+ - a job defined in `.repoactive.toml` gets the directory holding it (the
758
+ repo directory, by default);
759
+ - a job defined in `.repoactive.d/foo.toml` gets the `.repoactive.d`
760
+ directory.
761
+
762
+ ```toml
763
+ [job.fixup]
764
+ command = "$RA_CONFIG_SOURCE_DIR/fixup.sh"
765
+ title = "run the fixup script kept beside the config"
766
+ ```
767
+
768
+ The value is the config file's real location on disk, so it is the same
769
+ whether the file was discovered automatically or named with `-c`. A command
770
+ whose value came from a `--set` override has no config file, so
771
+ `RA_CONFIG_SOURCE_DIR` is unset for it.
772
+
661
773
  ### Overriding values on the command line
662
774
 
663
775
  `--set NAME=VALUE`/`-s` tweaks individual config values without editing a
@@ -831,12 +943,12 @@ changed anything in the current run, the job is skipped.
831
943
 
832
944
  ```toml
833
945
  [job.prek-autoupdate]
834
- command = "./scripts/prek.sh autoupdate"
946
+ command = "uvx prek autoupdate"
835
947
  title = "ci: update pre-commit hooks"
836
948
  tags = ["weekly"]
837
949
 
838
950
  [job.prek-run-all]
839
- command = "sh -c './scripts/prek.sh run -a; true'"
951
+ command = "sh -c 'uvx prek run -a; true'"
840
952
  title = "ci: apply pre-commit fixes"
841
953
  depends_on = ["prek-autoupdate"]
842
954
  run_only_if_changed = ["prek-autoupdate"]
@@ -962,8 +1074,8 @@ job per package in a monorepo, one per entry in a manifest - and you don't
962
1074
  want to hand-maintain them. A **generator** is an ordinary `[job.<name>]`
963
1075
  with `emits_jobs = true`. Instead of producing a diff, its command writes
964
1076
  one or more `*.toml` job fragments into the directory named by the
965
- `REPOACTIVE_JOBS_DIR` environment variable, and the jobs it emits run in the
966
- same invocation.
1077
+ `RA_JOBS_DIR` environment variable, and the jobs it emits run in the same
1078
+ invocation.
967
1079
 
968
1080
  ```toml
969
1081
  [job.per-package]
@@ -978,7 +1090,7 @@ cooldown_period = "7d"
978
1090
  The command writes fragments using the normal `[job.<name>]` syntax, e.g.
979
1091
 
980
1092
  ```toml
981
- # $REPOACTIVE_JOBS_DIR/pkg-a.toml
1093
+ # $RA_JOBS_DIR/pkg-a.toml
982
1094
  [job.upgrade-pkg-a]
983
1095
  command = "uv lock --upgrade --package pkg-a"
984
1096
  title = "build: upgrade pkg-a"
@@ -28,6 +28,7 @@ and (with `--mode publish`) the full MR lifecycle.
28
28
  - [Example](#example)
29
29
  - [`[platform.<name>]`](#platformname)
30
30
  - [Config file locations](#config-file-locations)
31
+ - [Variables passed to job commands](#variables-passed-to-job-commands)
31
32
  - [Overriding values on the command line](#overriding-values-on-the-command-line)
32
33
  - [Selecting jobs with tags](#selecting-jobs-with-tags)
33
34
  - [Keeping unmerged branches current](#keeping-unmerged-branches-current)
@@ -440,13 +441,17 @@ block.
440
441
  **Branch and commit options:**
441
442
 
442
443
  - **`branch_prefix`** (default: `"repoactive/"`) - Prefix prepended to the
443
- job name to form the branch name.
444
+ job name to form the branch name. Set to `""` to use the job name alone.
445
+ With an empty prefix the branch name is exactly the job name, so make sure
446
+ no job is named after your base branch (e.g. a job named `main`) - the two
447
+ branches would otherwise collide.
444
448
  - **`mr_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended to
445
449
  every MR/PR title. Set to `""` to disable.
446
450
  - **`commit_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended
447
451
  to every commit title. Set to `""` to disable.
448
452
  - **`base_branch`** (default: repo default) - Target branch for all jobs
449
- that do not set their own.
453
+ that do not set their own. May also be a jj revset expression such as
454
+ `trunk()`, `root()`, or a user-defined revset alias.
450
455
 
451
456
  **Run control:**
452
457
 
@@ -457,6 +462,10 @@ block.
457
462
  - **`timeout`** (default: `"2m"`) - Maximum runtime for a job's command.
458
463
  Same format as `cooldown_period`. See
459
464
  [Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
465
+ - **`shell`** (default: `/bin/sh`) - Interpreter used to run job commands. A
466
+ bare name is resolved on `PATH` (e.g. `"bash"`); an absolute path also
467
+ works. The command runs as `<shell> -c <command>`. Arguments are not
468
+ allowed - the value must be a single binary.
460
469
 
461
470
  ### `[job.<name>]`
462
471
 
@@ -491,7 +500,8 @@ underscores.
491
500
  **Branch and commit options:**
492
501
 
493
502
  - **`base_branch`** (default: inherited) - Target branch for this job's
494
- MR/PR.
503
+ MR/PR. May also be a jj revset expression such as `trunk()`, `root()`, or
504
+ a user-defined revset alias.
495
505
  - **`branch_prefix`** (default: inherited) - Override the branch-name prefix
496
506
  for this job only.
497
507
  - **`mr_title_prefix`** (default: inherited) - Override the MR/PR title
@@ -526,9 +536,12 @@ underscores.
526
536
  - **`timeout`** (default: inherited) - Maximum runtime for this job's
527
537
  command. Set to `"0s"` to disable the timeout entirely. See
528
538
  [Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
539
+ - **`shell`** (default: inherited) - Interpreter used to run this job's
540
+ command, overriding `job-defaults.shell`. See
541
+ [`job-defaults.shell`](#job-defaults).
529
542
  - **`emits_jobs`** (default: `false`) - Generator job: the command writes
530
- `*.toml` job fragments into `$REPOACTIVE_JOBS_DIR` instead of producing a
531
- diff. See [Generating jobs dynamically](#generating-jobs-dynamically).
543
+ `*.toml` job fragments into `$RA_JOBS_DIR` instead of producing a diff.
544
+ See [Generating jobs dynamically](#generating-jobs-dynamically).
532
545
 
533
546
  ### Stacking MRs
534
547
 
@@ -631,6 +644,105 @@ directory of `*.toml` files, and may be repeated to merge several sources;
631
644
  later sources win. Explicit paths are resolved relative to the current
632
645
  directory, not `--repo`.
633
646
 
647
+ ### Variables passed to job commands
648
+
649
+ `repoactive` injects a few `RA_`-prefixed environment variables into every
650
+ job command's environment. These are distinct from the `REPOACTIVE_`
651
+ variables [above](#environment-variables), which configure `repoactive`
652
+ itself.
653
+
654
+ | Variable | Value | Set when |
655
+ | ---------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
656
+ | `RA_JOB_NAME` | The name of the job the command belongs to. | Always. |
657
+ | `RA_WORKSPACE_DIR` | The temporary jj workspace created for the job (also the command's working directory). | Always. |
658
+ | `RA_JOB_BRANCH` | The bookmark/branch repoactive uses for the job's output. | Always. |
659
+ | `RA_JOB_BASE_BRANCH` | The branch the job's MR targets (`base_branch`, or `trunk()` by default). | Always. |
660
+ | `RA_CONFIG_SOURCE_DIR` | The directory of the config file that defined the command. | The command came from a config file (not a `--set` override or a built-in default). |
661
+ | `RA_JOBS_DIR` | The directory a generator writes its `*.toml` job fragments into. | The command is a [generator](#generating-jobs-dynamically) (`emits_jobs`). |
662
+
663
+ #### `RA_JOB_NAME`
664
+
665
+ `RA_JOB_NAME` holds the name of the job the command belongs to. A command
666
+ shared by several jobs (for instance one set in `[job-defaults]` or emitted
667
+ by a generator) can read it to tell which job is running:
668
+
669
+ ```toml
670
+ [job.lint]
671
+ command = "./run-check.sh $RA_JOB_NAME"
672
+ title = "run the shared check script for this job"
673
+ ```
674
+
675
+ #### `RA_WORKSPACE_DIR`
676
+
677
+ A job's command runs in a temporary jj workspace, which is also its working
678
+ directory. `RA_WORKSPACE_DIR` names that directory explicitly, so a command
679
+ that changes directory can still find its way back:
680
+
681
+ ```toml
682
+ [job.build]
683
+ command = "cd subdir && make && cp result $RA_WORKSPACE_DIR/subdir/"
684
+ title = "build in a subdirectory"
685
+ ```
686
+
687
+ #### `RA_JOB_BRANCH`
688
+
689
+ `RA_JOB_BRANCH` holds the bookmark (git branch) repoactive uses for the
690
+ job's output, e.g. `repoactive/uv-lock-upgrade`. The command runs on a fresh
691
+ commit while that bookmark still points at the **previous** run's commit, so
692
+ a command can inspect what it produced last time - for example to build on
693
+ it or diff against it:
694
+
695
+ ```toml
696
+ [job.notes]
697
+ command = "jj diff --from $RA_JOB_BRANCH --to @ > /tmp/delta || true; ./make-notes.sh"
698
+ title = "regenerate release notes from what changed since last run"
699
+ ```
700
+
701
+ The bookmark may not exist yet: a job's first run, a run that produced no
702
+ diff, and a generator all leave it unset on the remote, so a command that
703
+ reads it should tolerate a missing ref.
704
+
705
+ #### `RA_JOB_BASE_BRANCH`
706
+
707
+ `RA_JOB_BASE_BRANCH` holds the branch the job's merge request targets - its
708
+ `base_branch`, or `trunk()` when that is unset. A command can use it to look
709
+ at only what changed relative to the target, for instance to lint or
710
+ reformat just the affected files:
711
+
712
+ ```toml
713
+ [job.format]
714
+ command = "treefmt $(git diff --name-only $RA_JOB_BASE_BRANCH)"
715
+ title = "format files changed since the base branch"
716
+ ```
717
+
718
+ This is the branch you _configured_ as the base. When a job stacks on
719
+ another via `depends_on`, its commit actually sits on top of that other
720
+ job's output, so `RA_JOB_BASE_BRANCH` names the ultimate target of the
721
+ stack, not the immediate parent.
722
+
723
+ #### `RA_CONFIG_SOURCE_DIR`
724
+
725
+ Because the command runs in that throwaway workspace rather than in the
726
+ directory its config lives in, a relative path cannot reach a helper file
727
+ kept beside the config. `RA_CONFIG_SOURCE_DIR` bridges that: it holds the
728
+ directory of the config file that defined the job's command:
729
+
730
+ - a job defined in `.repoactive.toml` gets the directory holding it (the
731
+ repo directory, by default);
732
+ - a job defined in `.repoactive.d/foo.toml` gets the `.repoactive.d`
733
+ directory.
734
+
735
+ ```toml
736
+ [job.fixup]
737
+ command = "$RA_CONFIG_SOURCE_DIR/fixup.sh"
738
+ title = "run the fixup script kept beside the config"
739
+ ```
740
+
741
+ The value is the config file's real location on disk, so it is the same
742
+ whether the file was discovered automatically or named with `-c`. A command
743
+ whose value came from a `--set` override has no config file, so
744
+ `RA_CONFIG_SOURCE_DIR` is unset for it.
745
+
634
746
  ### Overriding values on the command line
635
747
 
636
748
  `--set NAME=VALUE`/`-s` tweaks individual config values without editing a
@@ -804,12 +916,12 @@ changed anything in the current run, the job is skipped.
804
916
 
805
917
  ```toml
806
918
  [job.prek-autoupdate]
807
- command = "./scripts/prek.sh autoupdate"
919
+ command = "uvx prek autoupdate"
808
920
  title = "ci: update pre-commit hooks"
809
921
  tags = ["weekly"]
810
922
 
811
923
  [job.prek-run-all]
812
- command = "sh -c './scripts/prek.sh run -a; true'"
924
+ command = "sh -c 'uvx prek run -a; true'"
813
925
  title = "ci: apply pre-commit fixes"
814
926
  depends_on = ["prek-autoupdate"]
815
927
  run_only_if_changed = ["prek-autoupdate"]
@@ -935,8 +1047,8 @@ job per package in a monorepo, one per entry in a manifest - and you don't
935
1047
  want to hand-maintain them. A **generator** is an ordinary `[job.<name>]`
936
1048
  with `emits_jobs = true`. Instead of producing a diff, its command writes
937
1049
  one or more `*.toml` job fragments into the directory named by the
938
- `REPOACTIVE_JOBS_DIR` environment variable, and the jobs it emits run in the
939
- same invocation.
1050
+ `RA_JOBS_DIR` environment variable, and the jobs it emits run in the same
1051
+ invocation.
940
1052
 
941
1053
  ```toml
942
1054
  [job.per-package]
@@ -951,7 +1063,7 @@ cooldown_period = "7d"
951
1063
  The command writes fragments using the normal `[job.<name>]` syntax, e.g.
952
1064
 
953
1065
  ```toml
954
- # $REPOACTIVE_JOBS_DIR/pkg-a.toml
1066
+ # $RA_JOBS_DIR/pkg-a.toml
955
1067
  [job.upgrade-pkg-a]
956
1068
  command = "uv lock --upgrade --package pkg-a"
957
1069
  title = "build: upgrade pkg-a"
@@ -10,7 +10,7 @@ only-include = ["src"]
10
10
 
11
11
  [project]
12
12
  name = "repoactive"
13
- version = "0.2.8.dev2"
13
+ version = "0.3.0"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -0,0 +1,51 @@
1
+ """Render and strip boxquote.el-style boxes used to embed command output in messages."""
2
+
3
+ import re
4
+
5
+ # A boxquote block: a ``,----`` opening line through its closing ``` `---- ``` line.
6
+ # DOTALL lets ``.*?`` span the body lines; MULTILINE anchors both delimiters to a
7
+ # line of their own. Non-greedy so each block stops at its own closing line.
8
+ _BOXQUOTE_BLOCK = re.compile(r"^,----.*?^`----[ \t]*$", re.MULTILINE | re.DOTALL)
9
+
10
+
11
+ def boxquote(msg: str, title: str = "") -> str:
12
+ """Render ``msg`` inside a boxquote.el-style box.
13
+
14
+ The box opens with ``,----[ title ]`` (or just ``,----`` when ``title`` is
15
+ empty), each line of ``msg`` is prefixed with ``| ``, and the box closes
16
+ with ``` `---- ```.
17
+
18
+ A multi-line ``title`` (e.g. a multi-line job command) is bracketed across
19
+ several lines instead of jammed onto the opening line: the first title line
20
+ follows ``,----[ ``, the remaining lines are ``|``-prefixed and indented to
21
+ align under that first line, the closing ``]`` trails the last of them, and
22
+ a blank ``|`` line then separates the bracketed title from ``msg``. A
23
+ trailing newline on ``title`` is dropped so it does not produce an empty
24
+ final title line.
25
+ """
26
+ body = "\n".join(f"| {line}" for line in msg.splitlines())
27
+ title = title.rstrip("\n")
28
+ if "\n" not in title:
29
+ top = f",----[ {title} ]" if title else ",----"
30
+ return f"{top}\n{body}\n`----"
31
+ open_bracket = ",----[ "
32
+ # Indent continuation lines so the title text aligns under the first line;
33
+ # the "|" replaces the leading "," so the bar still runs down the left edge.
34
+ indent = "|" + " " * (len(open_bracket) - 1)
35
+ head, *rest = title.split("\n")
36
+ header_lines = [f"{open_bracket}{head}", *(f"{indent}{line}" for line in rest[:-1])]
37
+ header_lines.append(f"{indent}{rest[-1]} ]")
38
+ header = "\n".join(header_lines)
39
+ return f"{header}\n|\n{body}\n`----"
40
+
41
+
42
+ def strip_boxquotes(text: str) -> str:
43
+ """Remove every boxquote block (see :func:`boxquote`) from ``text``.
44
+
45
+ A block runs from a ``,----`` opening line to its closing ``` `---- ``` line;
46
+ the surrounding text is kept. Blank lines exposed by a removed block are
47
+ collapsed so paragraphs stay separated by a single blank line, and leading
48
+ and trailing blank lines are trimmed.
49
+ """
50
+ without = _BOXQUOTE_BLOCK.sub("", text)
51
+ return re.sub(r"\n{3,}", "\n\n", without).strip("\n")
@@ -33,7 +33,8 @@ logger = logging.getLogger(__name__)
33
33
  _DURATION_RE = re.compile(r"^(\d+)([smhdw])$")
34
34
  _JOB_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
35
35
  _TAG_RE = re.compile(r"^[A-Za-z0-9_-]+$")
36
- _BRANCH_PREFIX_RE = re.compile(r"^(?!/)(?!.*//)[a-zA-Z0-9_\-/]+$")
36
+ # An empty prefix is allowed (branch name == job name); hence * not +.
37
+ _BRANCH_PREFIX_RE = re.compile(r"^(?!/)(?!.*//)[a-zA-Z0-9_\-/]*$")
37
38
  _DURATION_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
38
39
 
39
40
  # Reserved tags driving job selection. A plain job carries DEFAULT_TAG, which is
@@ -63,6 +64,17 @@ class InvalidBranchPrefixError(ValueError):
63
64
  )
64
65
 
65
66
 
67
+ class InvalidShellError(ValueError):
68
+ """Raised when a shell value is not a single interpreter binary."""
69
+
70
+ def __init__(self, value: str) -> None:
71
+ super().__init__(
72
+ f"invalid shell {value!r}: must be a single interpreter binary "
73
+ "(a name resolved on PATH like 'bash', or an absolute path); "
74
+ "arguments are not allowed"
75
+ )
76
+
77
+
66
78
  class InvalidJobNameError(ValueError):
67
79
  """Raised when a job name contains disallowed characters."""
68
80
 
@@ -157,6 +169,20 @@ class GeneratedByInBodyError(ValueError):
157
169
  )
158
170
 
159
171
 
172
+ class ConfigSourceDirInBodyError(ValueError):
173
+ """Raised when a [job.<name>] table sets a 'config_source_dir' field.
174
+
175
+ 'config_source_dir' is set by repoactive to the directory of the config
176
+ source that defined the job's command; it is not a user-facing config field.
177
+ """
178
+
179
+ def __init__(self, name: str) -> None:
180
+ super().__init__(
181
+ f"job {name!r} must not set 'config_source_dir'; "
182
+ "that field is set by repoactive to the job's config directory"
183
+ )
184
+
185
+
160
186
  class DuplicatePlatformHostError(ValueError):
161
187
  """Raised when two platforms resolve to the same host.
162
188
 
@@ -235,6 +261,16 @@ def _validate_duration(value: str) -> str:
235
261
  return value
236
262
 
237
263
 
264
+ def _validate_shell(value: str) -> str:
265
+ # The value maps to subprocess' executable= (run as `<shell> -c <command>`),
266
+ # so it must be a single binary, not an argv with flags. Reject empty and any
267
+ # whitespace so a "full argv" like "bash -e" fails at config load instead of
268
+ # silently looking up a binary literally named "bash -e".
269
+ if not value or any(c.isspace() for c in value):
270
+ raise InvalidShellError(value)
271
+ return value
272
+
273
+
238
274
  def _validate_single_line(value: str, info: ValidationInfo) -> str:
239
275
  if "\n" in value:
240
276
  assert info.field_name is not None
@@ -244,6 +280,7 @@ def _validate_single_line(value: str, info: ValidationInfo) -> str:
244
280
 
245
281
  _BranchPrefix = Annotated[str, AfterValidator(_validate_branch_prefix)]
246
282
  _Duration = Annotated[str, AfterValidator(_validate_duration)]
283
+ _Shell = Annotated[str, AfterValidator(_validate_shell)]
247
284
  # Titles and title prefixes end up in commit subjects and MR titles, which are
248
285
  # single-line by nature.
249
286
  _SingleLine = Annotated[str, AfterValidator(_validate_single_line)]
@@ -265,6 +302,10 @@ class JobDefaults(BaseModel):
265
302
  timeout: _Duration | None = "2m"
266
303
  auto_merge: bool = False
267
304
  required_approvals: int | None = Field(default=None, ge=0)
305
+ # Interpreter for job commands, passed to subprocess as executable= and run as
306
+ # `<shell> -c <command>`. None means /bin/sh (the shell=True default). May be a
307
+ # bare name resolved on PATH (e.g. "bash") or an absolute path.
308
+ shell: _Shell | None = None
268
309
 
269
310
 
270
311
  # Fields Job.resolve fills in from JobDefaults when the job does not set them
@@ -278,6 +319,7 @@ _DEFAULTED_FIELDS = (
278
319
  "timeout",
279
320
  "auto_merge",
280
321
  "required_approvals",
322
+ "shell",
281
323
  )
282
324
 
283
325
 
@@ -303,13 +345,18 @@ class Job(BaseModel):
303
345
  output_in_commit: bool = True
304
346
  # When true, the command does not produce a diff to commit; instead it writes
305
347
  # one or more *.toml job fragments into the directory named by the
306
- # REPOACTIVE_JOBS_DIR environment variable, and those jobs are run in the same
348
+ # RA_JOBS_DIR environment variable, and those jobs are run in the same
307
349
  # invocation. See docs/adr/0004-job-generators.md.
308
350
  emits_jobs: bool = False
309
351
  # Set by repoactive (never written in config) on jobs produced by a generator:
310
352
  # the generator's name, recorded as a second Repoactive-Job trailer so the
311
353
  # generator gets a meaningful cooldown over the whole fan-out.
312
354
  generated_by: str | None = None
355
+ # Set by repoactive (never written in config): the absolute directory of the
356
+ # config source that defined this job's command, exported to the command as
357
+ # RA_CONFIG_SOURCE_DIR so it can reach files kept beside its config. None when
358
+ # the command came from a --set override or the built-in defaults.
359
+ config_source_dir: str | None = None
313
360
 
314
361
  # the following fields will be resolved from the defaults
315
362
  branch_prefix: _BranchPrefix | None = None
@@ -320,6 +367,7 @@ class Job(BaseModel):
320
367
  timeout: _Duration | None = None
321
368
  auto_merge: bool | None = None
322
369
  required_approvals: int | None = Field(default=None, ge=0)
370
+ shell: _Shell | None = None
323
371
 
324
372
  @field_validator("name")
325
373
  @classmethod
@@ -432,6 +480,8 @@ class Config(BaseModel):
432
480
  raise JobNameInBodyError(name)
433
481
  if "generated_by" in body:
434
482
  raise GeneratedByInBodyError(name)
483
+ if "config_source_dir" in body:
484
+ raise ConfigSourceDirInBodyError(name)
435
485
  jobs.append({**body, "name": name})
436
486
  return jobs
437
487
 
@@ -517,8 +567,18 @@ class Config(BaseModel):
517
567
  return {job.branch_name() for job in self._resolved_jobs()}
518
568
 
519
569
  def base_branches(self) -> set[str]:
520
- """All branches a job uses as base_branch."""
521
- return {job.base_branch for job in self._resolved_jobs() if job.base_branch}
570
+ """Bookmark names a job uses as base_branch.
571
+
572
+ These are the branches to track with ``jj bookmark track``. Values that
573
+ look like a revset function call (``trunk()``, ``root()``, or a
574
+ user-defined revset alias) are excluded: they are valid base branches but
575
+ not bookmarks, so tracking them would fail.
576
+ """
577
+ return {
578
+ job.base_branch
579
+ for job in self._resolved_jobs()
580
+ if job.base_branch and "()" not in job.base_branch
581
+ }
522
582
 
523
583
  def token_env_names(self) -> set[str]:
524
584
  """Names of the env vars holding platform API tokens.
@@ -657,6 +717,10 @@ class _ConfigSource:
657
717
 
658
718
  label: str
659
719
  data: dict
720
+ # Absolute directory of the file this source was read from, or None for
721
+ # sources without a file (built-in defaults, --set overrides). Exposed to a
722
+ # command as RA_CONFIG_SOURCE_DIR for the job whose command this source sets.
723
+ source_dir: Path | None = None
660
724
 
661
725
 
662
726
  def _built_in_defaults() -> list[_ConfigSource]:
@@ -693,7 +757,9 @@ def _parse_override(text: str) -> _ConfigSource:
693
757
 
694
758
  def _read_toml_file(path: Path) -> _ConfigSource:
695
759
  try:
696
- return _ConfigSource(str(path), tomllib.loads(path.read_text()))
760
+ return _ConfigSource(
761
+ str(path), tomllib.loads(path.read_text()), source_dir=path.resolve().parent
762
+ )
697
763
  except (OSError, tomllib.TOMLDecodeError) as e:
698
764
  raise ConfigError(str(path), e) from e
699
765
 
@@ -710,12 +776,19 @@ def load_config(paths: list[Path], overrides: list[str] | None = None) -> Config
710
776
 
711
777
  def _merge_config(sources: Iterable[_ConfigSource]) -> Config:
712
778
  merged = {}
779
+ # Directory of the config source that last set each job's command, keyed by
780
+ # job name. Exported to the command as RA_CONFIG_SOURCE_DIR (see
781
+ # Job.config_source_dir). None when that source has no file (--set, built-in).
782
+ command_source_dir: dict[str, Path | None] = {}
713
783
  for source in sources:
714
784
  data = source.data
715
785
  try:
716
786
  # Reject odd shapes (e.g. job.foo = "hello") up front; the merge
717
787
  # helpers assume tables of tables and would fail cryptically.
718
788
  shape = ConfigShape.model_validate(data)
789
+ for name, body in shape.job.items():
790
+ if "command" in body:
791
+ command_source_dir[name] = source.source_dir
719
792
  jobs = merge_jobs(base=merged.get("job", {}), override=shape.job)
720
793
  platforms = merge_platforms(
721
794
  base=merged.get("platform", {}),
@@ -732,5 +805,18 @@ def _merge_config(sources: Iterable[_ConfigSource]) -> Config:
732
805
  except (ValueError, ValidationError) as e:
733
806
  raise ConfigError(source.label, e) from e
734
807
  config = Config.model_validate(merged)
808
+ # Stamp each job with the directory of the source that defined its command.
809
+ # Done after validation (not injected into the raw table) so it bypasses the
810
+ # ConfigSourceDirInBodyError guard, which only rejects user-written values.
811
+ config = config.model_copy(
812
+ update={
813
+ "jobs": [
814
+ job.model_copy(update={"config_source_dir": str(command_source_dir[job.name])})
815
+ if command_source_dir.get(job.name) is not None
816
+ else job
817
+ for job in config.jobs
818
+ ]
819
+ }
820
+ )
735
821
  logger.debug("loaded config: %s", config.model_dump_json(indent=2))
736
822
  return config
@@ -49,7 +49,37 @@ logger = logging.getLogger(__name__)
49
49
 
50
50
  # Environment variable naming the directory a generator (``emits_jobs``) command
51
51
  # writes its ``*.toml`` job fragments into. See docs/adr/0004-job-generators.md.
52
- REPOACTIVE_JOBS_DIR_ENV = "REPOACTIVE_JOBS_DIR"
52
+ RA_JOBS_DIR_ENV = "RA_JOBS_DIR"
53
+
54
+ # Environment variable exposing to a job command the directory of the config
55
+ # source that defined the command (Job.config_source_dir), so the command can
56
+ # reach files kept beside its config. See docs/adr/0016-injected-env-var-prefix.md.
57
+ RA_CONFIG_SOURCE_DIR_ENV = "RA_CONFIG_SOURCE_DIR"
58
+
59
+ # Environment variable exposing to a job command the throwaway jj workspace
60
+ # repoactive created for it. This is always the command's working directory, but
61
+ # naming it explicitly lets a command that changes directory find its way back.
62
+ RA_WORKSPACE_DIR_ENV = "RA_WORKSPACE_DIR"
63
+
64
+ # Environment variable exposing to a job command the bookmark/branch repoactive
65
+ # uses for the job's output (Job.branch_name). The command runs on a fresh commit
66
+ # while this bookmark still points at the previous run's commit, so the command
67
+ # can inspect what it produced last time (e.g. `git diff $RA_JOB_BRANCH`). The
68
+ # bookmark may not exist yet: a first run, a run that produced no diff, or a
69
+ # generator never creates it.
70
+ RA_JOB_BRANCH_ENV = "RA_JOB_BRANCH"
71
+
72
+ # Environment variable exposing to a job command the name of the job it belongs
73
+ # to (Job.name), so a command shared by several jobs can tell which one is
74
+ # running (e.g. to label its output).
75
+ RA_JOB_NAME_ENV = "RA_JOB_NAME"
76
+
77
+ # Environment variable exposing to a job command the branch the job's MR targets
78
+ # (Job.base_branch, or "trunk()" by default), so a command can diff against its
79
+ # target (e.g. `git diff $RA_JOB_BASE_BRANCH`). This is the *configured* base; for a
80
+ # stacked job (depends_on) the immediate parent commit is another job's output,
81
+ # not this value.
82
+ RA_JOB_BASE_BRANCH_ENV = "RA_JOB_BASE_BRANCH"
53
83
 
54
84
  # Fields an emitted job inherits from its generator when the emitted entry does
55
85
  # not set them itself (``tags`` and ``depends_on`` are handled separately because
@@ -245,7 +275,7 @@ def _command_env(
245
275
  Starts from the inherited environment (so the command still sees PATH etc.),
246
276
  drops the platform token variables (``secret_env_names``) so a command cannot read
247
277
  the credential repoactive uses to push/create MRs, then layers on ``extra_env``
248
- (e.g. REPOACTIVE_JOBS_DIR for a generator). See
278
+ (e.g. RA_JOBS_DIR for a generator). See
249
279
  docs/adr/0006-job-commands-are-trusted.md.
250
280
  """
251
281
  env = {k: v for k, v in os.environ.items() if k not in secret_env_names}
@@ -254,6 +284,25 @@ def _command_env(
254
284
  return env
255
285
 
256
286
 
287
+ def _job_extra_env(job: Job, extra: dict[str, str] | None = None) -> dict[str, str]:
288
+ """Extra environment for ``job``'s command: its name, branches, config dir, ``extra``.
289
+
290
+ Always adds RA_JOB_NAME (the job's name), RA_JOB_BRANCH (the bookmark
291
+ repoactive uses for the job's output), and RA_JOB_BASE_BRANCH (the branch the
292
+ job's MR targets). Adds RA_CONFIG_SOURCE_DIR when the job has a
293
+ ``config_source_dir`` (the directory of the config source that defined its
294
+ command), on top of any caller-supplied entries (e.g. RA_JOBS_DIR for a
295
+ generator).
296
+ """
297
+ env = dict(extra or {})
298
+ env[RA_JOB_NAME_ENV] = job.name
299
+ env[RA_JOB_BRANCH_ENV] = job.branch_name()
300
+ env[RA_JOB_BASE_BRANCH_ENV] = job.base_branch or "trunk()"
301
+ if job.config_source_dir is not None:
302
+ env[RA_CONFIG_SOURCE_DIR_ENV] = job.config_source_dir
303
+ return env
304
+
305
+
257
306
  @contextlib.contextmanager
258
307
  def _watchdog(proc: subprocess.Popen[str], timeout: float | None) -> Generator[threading.Event]:
259
308
  """Kill ``proc``'s process group if it outlives ``timeout`` seconds.
@@ -298,6 +347,9 @@ def _spawn(job: Job, cwd: Path, env: dict[str, str]) -> Generator[subprocess.Pop
298
347
  proc = subprocess.Popen(
299
348
  job.command,
300
349
  shell=True,
350
+ # None keeps subprocess' shell=True default of /bin/sh; a configured shell
351
+ # runs the command as `<shell> -c <command>` (see Job.shell).
352
+ executable=job.shell,
301
353
  cwd=cwd,
302
354
  stdout=subprocess.PIPE,
303
355
  stderr=subprocess.STDOUT,
@@ -327,7 +379,12 @@ def _run_command(
327
379
  extra_env: dict[str, str] | None = None,
328
380
  ) -> CommandResult:
329
381
  start = time.monotonic()
330
- env = _command_env(extra_env=extra_env, secret_env_names=secret_env_names)
382
+ # The workspace is always cwd, but expose it explicitly so a command that
383
+ # cd's elsewhere can still find the workspace repoactive prepared for it.
384
+ env = _command_env(
385
+ extra_env={**(extra_env or {}), RA_WORKSPACE_DIR_ENV: str(cwd)},
386
+ secret_env_names=secret_env_names,
387
+ )
331
388
 
332
389
  # Stream the merged stdout/stderr line by line: keep the full output (needed
333
390
  # for the commit message and the success result) while feeding a live tail of
@@ -498,7 +555,12 @@ def run_job(
498
555
  repo.git_sync_head()
499
556
  logger.debug("[%s] running command: %s", job.name, job.command)
500
557
  try:
501
- command_result = _run_command(job, repo.cwd, secret_env_names=ctx.secret_env_names)
558
+ command_result = _run_command(
559
+ job,
560
+ repo.cwd,
561
+ secret_env_names=ctx.secret_env_names,
562
+ extra_env=_job_extra_env(job),
563
+ )
502
564
  except CommandError:
503
565
  # The command timed out or failed; discard only the fresh commit.
504
566
  # The old bookmark is completely unaffected.
@@ -571,6 +633,9 @@ def _build_generated_job(
571
633
  for f in _INHERITED_FIELDS:
572
634
  merged.setdefault(f, getattr(generator, f))
573
635
  merged["generated_by"] = generator.name
636
+ # The generator's fragments live in a throwaway temp dir, so an emitted job's
637
+ # meaningful config location is the generator's own config source.
638
+ merged["config_source_dir"] = generator.config_source_dir
574
639
  try:
575
640
  return Job.model_validate(merged)
576
641
  except ValidationError as e:
@@ -785,7 +850,7 @@ def _run_generator_job(
785
850
  """Run a generator and return its emitted jobs (resolved ``job`` required).
786
851
 
787
852
  The command runs in a fresh workspace on top of ``parents`` with
788
- ``REPOACTIVE_JOBS_DIR`` pointing at an empty directory; it writes ``*.toml``
853
+ ``RA_JOBS_DIR`` pointing at an empty directory; it writes ``*.toml``
789
854
  fragments there which are parsed once it exits. The generator itself produces
790
855
  no diff: any working-copy change it leaves is discarded (ADR 0004), and a
791
856
  no-op ``JobResult`` is recorded so its emitted jobs (which depend on it)
@@ -808,7 +873,7 @@ def _run_generator_job(
808
873
  job,
809
874
  repo.cwd,
810
875
  secret_env_names=ctx.secret_env_names,
811
- extra_env={REPOACTIVE_JOBS_DIR_ENV: str(jobs_dir)},
876
+ extra_env=_job_extra_env(job, {RA_JOBS_DIR_ENV: str(jobs_dir)}),
812
877
  )
813
878
  specs = _load_job_specs(jobs_dir)
814
879
  finally:
@@ -1,32 +0,0 @@
1
- """Render and strip boxquote.el-style boxes used to embed command output in messages."""
2
-
3
- import re
4
-
5
- # A boxquote block: a ``,----`` opening line through its closing ``` `---- ``` line.
6
- # DOTALL lets ``.*?`` span the body lines; MULTILINE anchors both delimiters to a
7
- # line of their own. Non-greedy so each block stops at its own closing line.
8
- _BOXQUOTE_BLOCK = re.compile(r"^,----.*?^`----[ \t]*$", re.MULTILINE | re.DOTALL)
9
-
10
-
11
- def boxquote(msg: str, title: str = "") -> str:
12
- """Render ``msg`` inside a boxquote.el-style box.
13
-
14
- The first line is ``,----[ title ]`` (or just ``,----`` when ``title`` is
15
- empty), each line of ``msg`` is prefixed with ``| ``, and the box closes
16
- with ``` `---- ```.
17
- """
18
- top = f",----[ {title} ]" if title else ",----"
19
- body = "\n".join(f"| {line}" for line in msg.splitlines())
20
- return f"{top}\n{body}\n`----"
21
-
22
-
23
- def strip_boxquotes(text: str) -> str:
24
- """Remove every boxquote block (see :func:`boxquote`) from ``text``.
25
-
26
- A block runs from a ``,----`` opening line to its closing ``` `---- ``` line;
27
- the surrounding text is kept. Blank lines exposed by a removed block are
28
- collapsed so paragraphs stay separated by a single blank line, and leading
29
- and trailing blank lines are trimmed.
30
- """
31
- without = _BOXQUOTE_BLOCK.sub("", text)
32
- return re.sub(r"\n{3,}", "\n\n", without).strip("\n")
File without changes
File without changes