@@ -273,6 +273,46 @@ schedule to ensure you are executing the correct binaries in your
schedule.
+BACKGROUND MAINTENANCE ON MACOS SYSTEMS
+---------------------------------------
+
+While macOS technically supports `cron`, using `crontab -e` requires
+elevated privileges and the executed process does not have a full user
+context. Without a full user context, Git and its credential helpers
+cannot access stored credentials, so some maintenance tasks are not
+functional.
+
+Instead, `git maintenance start` interacts with the `launchctl` tool,
+which is the recommended way to schedule timed jobs in macOS. Scheduling
+maintenance through `git maintenance (start|stop)` requires some
+`launchctl` features available only in macOS 10.11 or later.
+
+Your user-specific scheduled tasks are stored as XML-formatted `.plist`
+files in `~/Library/LaunchAgents/`. You can see the currently-registered
+tasks using the following command:
+
+-----------------------------------------------------------------------
+$ ls ~/Library/LaunchAgents/org.git-scm.git*
+org.git-scm.git.daily.plist
+org.git-scm.git.hourly.plist
+org.git-scm.git.weekly.plist
+-----------------------------------------------------------------------
+
+One task is registered for each `--schedule=<frequency>` option. To
+inspect how the XML format describes each schedule, open one of these
+`.plist` files in an editor and inspect the `<array>` element following
+the `<key>StartCalendarInterval</key>` element.
+
+`git maintenance start` will overwrite these files and register the
+tasks again with `launchctl`, so any customizations should be done by
+creating your own `.plist` files with distinct names. Similarly, the
+`git maintenance stop` command will unregister the tasks with `launchctl`
+and delete the `.plist` files.
+
+To create more advanced customizations to your background tasks, see
+launchctl.plist(5) for more information.
+
+
GIT
---
Part of the linkgit:git[1] suite
@@ -1491,6 +1491,186 @@ static int maintenance_unregister(void)
return run_command(&config_unset);
}
+static const char *get_frequency(enum schedule_priority schedule)
+{
+ switch (schedule) {
+ case SCHEDULE_HOURLY:
+ return "hourly";
+ case SCHEDULE_DAILY:
+ return "daily";
+ case SCHEDULE_WEEKLY:
+ return "weekly";
+ default:
+ BUG("invalid schedule %d", schedule);
+ }
+}
+
+static char *launchctl_service_name(const char *frequency)
+{
+ struct strbuf label = STRBUF_INIT;
+ strbuf_addf(&label, "org.git-scm.git.%s", frequency);
+ return strbuf_detach(&label, NULL);
+}
+
+static char *launchctl_service_filename(const char *name)
+{
+ char *expanded;
+ struct strbuf filename = STRBUF_INIT;
+ strbuf_addf(&filename, "~/Library/LaunchAgents/%s.plist", name);
+
+ expanded = expand_user_path(filename.buf, 1);
+ if (!expanded)
+ die(_("failed to expand path '%s'"), filename.buf);
+
+ strbuf_release(&filename);
+ return expanded;
+}
+
+static char *launchctl_get_uid(void)
+{
+ return xstrfmt("gui/%d", getuid());
+}
+
+static int launchctl_boot_plist(int enable, const char *filename, const char *cmd)
+{
+ int result;
+ struct child_process child = CHILD_PROCESS_INIT;
+ char *uid = launchctl_get_uid();
+
+ strvec_split(&child.args, cmd);
+ if (enable)
+ strvec_push(&child.args, "bootstrap");
+ else
+ strvec_push(&child.args, "bootout");
+ strvec_push(&child.args, uid);
+ strvec_push(&child.args, filename);
+
+ child.no_stderr = 1;
+ child.no_stdout = 1;
+
+ if (start_command(&child))
+ die(_("failed to start launchctl"));
+
+ result = finish_command(&child);
+
+ free(uid);
+ return result;
+}
+
+static int launchctl_remove_plist(enum schedule_priority schedule, const char *cmd)
+{
+ const char *frequency = get_frequency(schedule);
+ char *name = launchctl_service_name(frequency);
+ char *filename = launchctl_service_filename(name);
+ int result = launchctl_boot_plist(0, filename, cmd);
+ unlink(filename);
+ free(filename);
+ free(name);
+ return result;
+}
+
+static int launchctl_remove_plists(const char *cmd)
+{
+ return launchctl_remove_plist(SCHEDULE_HOURLY, cmd) ||
+ launchctl_remove_plist(SCHEDULE_DAILY, cmd) ||
+ launchctl_remove_plist(SCHEDULE_WEEKLY, cmd);
+}
+
+static int launchctl_schedule_plist(const char *exec_path, enum schedule_priority schedule, const char *cmd)
+{
+ FILE *plist;
+ int i;
+ const char *preamble, *repeat;
+ const char *frequency = get_frequency(schedule);
+ char *name = launchctl_service_name(frequency);
+ char *filename = launchctl_service_filename(name);
+
+ if (safe_create_leading_directories(filename))
+ die(_("failed to create directories for '%s'"), filename);
+ plist = xfopen(filename, "w");
+
+ preamble = "<?xml version=\"1.0\" encoding=\"US-ASCII\"?>\n"
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
+ "<plist version=\"1.0\">"
+ "<dict>\n"
+ "<key>Label</key><string>%s</string>\n"
+ "<key>ProgramArguments</key>\n"
+ "<array>\n"
+ "<string>%s/git</string>\n"
+ "<string>--exec-path=%s</string>\n"
+ "<string>for-each-repo</string>\n"
+ "<string>--config=maintenance.repo</string>\n"
+ "<string>maintenance</string>\n"
+ "<string>run</string>\n"
+ "<string>--schedule=%s</string>\n"
+ "</array>\n"
+ "<key>StartCalendarInterval</key>\n"
+ "<array>\n";
+ fprintf(plist, preamble, name, exec_path, exec_path, frequency);
+
+ switch (schedule) {
+ case SCHEDULE_HOURLY:
+ repeat = "<dict>\n"
+ "<key>Hour</key><integer>%d</integer>\n"
+ "<key>Minute</key><integer>0</integer>\n"
+ "</dict>\n";
+ for (i = 1; i <= 23; i++)
+ fprintf(plist, repeat, i);
+ break;
+
+ case SCHEDULE_DAILY:
+ repeat = "<dict>\n"
+ "<key>Day</key><integer>%d</integer>\n"
+ "<key>Hour</key><integer>0</integer>\n"
+ "<key>Minute</key><integer>0</integer>\n"
+ "</dict>\n";
+ for (i = 1; i <= 6; i++)
+ fprintf(plist, repeat, i);
+ break;
+
+ case SCHEDULE_WEEKLY:
+ fprintf(plist,
+ "<dict>\n"
+ "<key>Day</key><integer>0</integer>\n"
+ "<key>Hour</key><integer>0</integer>\n"
+ "<key>Minute</key><integer>0</integer>\n"
+ "</dict>\n");
+ break;
+
+ default:
+ /* unreachable */
+ break;
+ }
+ fprintf(plist, "</array>\n</dict>\n</plist>\n");
+ fclose(plist);
+
+ /* bootout might fail if not already running, so ignore */
+ launchctl_boot_plist(0, filename, cmd);
+ if (launchctl_boot_plist(1, filename, cmd))
+ die(_("failed to bootstrap service %s"), filename);
+
+ free(filename);
+ free(name);
+ return 0;
+}
+
+static int launchctl_add_plists(const char *cmd)
+{
+ const char *exec_path = git_exec_path();
+
+ return launchctl_schedule_plist(exec_path, SCHEDULE_HOURLY, cmd) ||
+ launchctl_schedule_plist(exec_path, SCHEDULE_DAILY, cmd) ||
+ launchctl_schedule_plist(exec_path, SCHEDULE_WEEKLY, cmd);
+}
+
+static int launchctl_update_schedule(int run_maintenance, int fd, const char *cmd)
+{
+ if (run_maintenance)
+ return launchctl_add_plists(cmd);
+ else
+ return launchctl_remove_plists(cmd);
+}
+
#define BEGIN_LINE "# BEGIN GIT MAINTENANCE SCHEDULE"
#define END_LINE "# END GIT MAINTENANCE SCHEDULE"
@@ -1579,7 +1759,11 @@ static int crontab_update_schedule(int run_maintenance, int fd, const char *cmd)
return result;
}
+#if defined(__APPLE__)
+static const char platform_scheduler[] = "launchctl";
+#else
static const char platform_scheduler[] = "crontab";
+#endif
static int update_background_schedule(int enable)
{
@@ -1603,7 +1787,9 @@ static int update_background_schedule(int enable)
if (hold_lock_file_for_update(&lk, lock_path, LOCK_NO_DEREF) < 0)
return error(_("another process is scheduling background maintenance"));
- if (!strcmp(scheduler, "crontab"))
+ if (!strcmp(scheduler, "launchctl"))
+ result = launchctl_update_schedule(enable, lk.tempfile->fd, cmd);
+ else if (!strcmp(scheduler, "crontab"))
result = crontab_update_schedule(enable, lk.tempfile->fd, cmd);
else
die("unknown background scheduler: %s", scheduler);
@@ -7,6 +7,19 @@ test_description='git maintenance builtin'
GIT_TEST_COMMIT_GRAPH=0
GIT_TEST_MULTI_PACK_INDEX=0
+test_lazy_prereq XMLLINT '
+ xmllint --version
+'
+
+test_xmllint () {
+ if test_have_prereq XMLLINT
+ then
+ xmllint --noout "$@"
+ else
+ true
+ fi
+}
+
test_expect_success 'help text' '
test_expect_code 129 git maintenance -h 2>err &&
test_i18ngrep "usage: git maintenance <subcommand>" err &&
@@ -395,6 +408,52 @@ test_expect_success 'start preserves existing schedule' '
grep "Important information!" cron.txt
'
+test_expect_success 'start and stop macOS maintenance' '
+ # ensure $HOME can be compared against hook arguments on all platforms
+ pfx=$(cd "$HOME" && pwd) &&
+
+ write_script print-args <<-\EOF &&
+ echo $* | sed "s:gui/[0-9][0-9]*:gui/[UID]:" >>args
+ EOF
+
+ rm -f args &&
+ GIT_TEST_MAINT_SCHEDULER=launchctl:./print-args git maintenance start &&
+
+ # start registers the repo
+ git config --get --global maintenance.repo "$(pwd)" &&
+
+ ls "$HOME/Library/LaunchAgents" >actual &&
+ cat >expect <<-\EOF &&
+ org.git-scm.git.daily.plist
+ org.git-scm.git.hourly.plist
+ org.git-scm.git.weekly.plist
+ EOF
+ test_cmp expect actual &&
+
+ rm -f expect &&
+ for frequency in hourly daily weekly
+ do
+ PLIST="$pfx/Library/LaunchAgents/org.git-scm.git.$frequency.plist" &&
+ test_xmllint "$PLIST" &&
+ grep schedule=$frequency "$PLIST" &&
+ echo "bootout gui/[UID] $PLIST" >>expect &&
+ echo "bootstrap gui/[UID] $PLIST" >>expect || return 1
+ done &&
+ test_cmp expect args &&
+
+ rm -f args &&
+ GIT_TEST_MAINT_SCHEDULER=launchctl:./print-args git maintenance stop &&
+
+ # stop does not unregister the repo
+ git config --get --global maintenance.repo "$(pwd)" &&
+
+ printf "bootout gui/[UID] $pfx/Library/LaunchAgents/org.git-scm.git.%s.plist\n" \
+ hourly daily weekly >expect &&
+ test_cmp expect args &&
+ ls "$HOME/Library/LaunchAgents" >actual &&
+ test_line_count = 0 actual
+'
+
test_expect_success 'register preserves existing strategy' '
git config maintenance.strategy none &&
git maintenance register &&