Skip to content

Commit 49cecb9

Browse files
committed
maintenance: use launchctl on macOS
The existing mechanism for scheduling background maintenance is done through cron. The 'crontab -e' command allows updating the schedule while cron itself runs those commands. While this is technically supported by macOS, it has some significant deficiencies: 1. Every run of 'crontab -e' must request elevated privileges through the user interface. When running 'git maintenance start' from the Terminal app, it presents a dialog box saying "Terminal.app would like to administer your computer. Administration can include modifying passwords, networking, and system settings." This is more alarming than what we are hoping to achieve. If this alert had some information about how "git" is trying to run "crontab" then we would have some reason to believe that this dialog might be fine. However, it also doesn't help that some scenarios just leave Git waiting for a response without presenting anything to the user. I experienced this when executing the command from a Bash terminal view inside Visual Studio Code. 2. While cron initializes a user environment enough for "git config --global --show-origin" to show the correct config file information, it does not set up the environment enough for Git Credential Manager Core to load credentials during a 'prefetch' task. My prefetches against private repositories required re-authenticating through UI pop-ups in a way that should not be required. The solution is to switch from cron to the Apple-recommended [1] 'launchd' tool. [1] https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html The basics of this tool is that we need to create XML-formatted "plist" files inside "~/Library/LaunchAgents/" and then use the 'launchctl' tool to make launchd aware of them. The plist files include all of the scheduling information, along with the command-line arguments split across an array of <string> tags. For example, here is my plist file for the weekly scheduled tasks: <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict> <key>Label</key><string>org.git-scm.git.weekly</string> <key>ProgramArguments</key> <array> <string>/usr/local/libexec/git-core/git</string> <string>--exec-path=/usr/local/libexec/git-core</string> <string>for-each-repo</string> <string>--config=maintenance.repo</string> <string>maintenance</string> <string>run</string> <string>--schedule=weekly</string> </array> <key>StartCalendarInterval</key> <array> <dict> <key>Day</key><integer>0</integer> <key>Hour</key><integer>0</integer> <key>Minute</key><integer>0</integer> </dict> </array> </dict> </plist> The schedules for the daily and hourly tasks are more complicated since we need to use an array for the StartCalendarInterval with an entry for each of the six days other than the 0th day (to avoid colliding with the weekly task), and each of the 23 hours other than the 0th hour (to avoid colliding with the daily task). The "Label" value is currently filled with "org.git-scm.git.X" where X is the frequency. We need a different plist file for each frequency. The launchctl command needs to be aligned with a user id in order to initialize the command environment. This must be done using the 'launchctl bootstrap' subcommand. This subcommand is new as of macOS 10.11, which was released in September 2015. Before that release the 'launchctl load' subcommand was recommended. The best source of information on this transition I have seen is available at [2]. [2] https://babodee.wordpress.com/2016/04/09/launchctl-2-0-syntax/ To remove a schedule, we must run 'launchctl bootout' with a valid plist file. We also need to 'bootout' a task before the 'bootstrap' subcommand will succeed, if such a task already exists. We can verify the commands that were run by 'git maintenance start' and 'git maintenance stop' by injecting a script that writes the command-line arguments into GIT_TEST_CRONTAB. Signed-off-by: Derrick Stolee <[email protected]>
1 parent 709a173 commit 49cecb9

File tree

4 files changed

+305
-3
lines changed

4 files changed

+305
-3
lines changed

Documentation/git-maintenance.txt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,49 @@ for advanced scheduling techniques. Please do use the full path and
273273
executing the correct binaries in your schedule.
274274

275275

276+
BACKGROUND MAINTENANCE ON MACOS SYSTEMS
277+
---------------------------------------
278+
279+
While macOS technically supports `cron`, using `crontab -e` requires
280+
elevated privileges and the executed process do not have a full user
281+
context. Without a full user context, Git and its credential helpers
282+
cannot access stored credentials, so some maintenance tasks are not
283+
functional.
284+
285+
Instead, `git maintenance start` interacts with the `launchctl` tool,
286+
which is the recommended way to
287+
https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html[schedule timed jobs in macOS].
288+
289+
Scheduling maintenance through `git maintenance (start|stop)` requires
290+
some `launchctl` features available only in macOS 10.11 or later.
291+
292+
Your user-specific scheduled tasks are stored as XML-formatted `.plist`
293+
files in `~/Library/LaunchAgents/`. You can see the currently-registered
294+
tasks using the following command:
295+
296+
-----------------------------------------------------------------------
297+
$ ls ~/Library/LaunchAgents/ | grep org.git-scm.git
298+
org.git-scm.git.daily.plist
299+
org.git-scm.git.hourly.plist
300+
org.git-scm.git.weekly.plist
301+
-----------------------------------------------------------------------
302+
303+
One task is registered for each `--schedule=<frequency>` option. To
304+
inspect how the XML format describes each schedule, open one of these
305+
`.plist` files in an editor and inspect the `<array>` element following
306+
the `<key>StartCalendarInterval</key>` element.
307+
308+
`git maintenance start` will overwrite these files and register the
309+
tasks again with `launchctl`, so any customizations should be done by
310+
creating your own `.plist` files with distinct names. Similarly, the
311+
`git maintenance stop` command will unregister the tasks with `launchctl`
312+
and delete the `.plist` files.
313+
314+
To create more advanced customizations to your background tasks, see
315+
https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html#//apple_ref/doc/uid/TP40001762-104142[the `launchctl` documentation]
316+
for more information.
317+
318+
276319
GIT
277320
---
278321
Part of the linkgit:git[1] suite

builtin/gc.c

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,6 +1491,214 @@ static int maintenance_unregister(void)
14911491
return run_command(&config_unset);
14921492
}
14931493

1494+
#if defined(__APPLE__)
1495+
1496+
static char *get_service_name(const char *frequency)
1497+
{
1498+
struct strbuf label = STRBUF_INIT;
1499+
strbuf_addf(&label, "org.git-scm.git.%s", frequency);
1500+
return strbuf_detach(&label, NULL);
1501+
}
1502+
1503+
static char *get_service_filename(const char *name)
1504+
{
1505+
char *expanded;
1506+
struct strbuf filename = STRBUF_INIT;
1507+
strbuf_addf(&filename, "~/Library/LaunchAgents/%s.plist", name);
1508+
1509+
expanded = expand_user_path(filename.buf, 1);
1510+
if (!expanded)
1511+
die(_("failed to expand path '%s'"), filename.buf);
1512+
1513+
strbuf_release(&filename);
1514+
return expanded;
1515+
}
1516+
1517+
static const char *get_frequency(enum schedule_priority schedule)
1518+
{
1519+
switch (schedule) {
1520+
case SCHEDULE_HOURLY:
1521+
return "hourly";
1522+
case SCHEDULE_DAILY:
1523+
return "daily";
1524+
case SCHEDULE_WEEKLY:
1525+
return "weekly";
1526+
default:
1527+
BUG("invalid schedule %d", schedule);
1528+
}
1529+
}
1530+
1531+
static char *get_uid(void)
1532+
{
1533+
struct strbuf output = STRBUF_INIT;
1534+
struct child_process id = CHILD_PROCESS_INIT;
1535+
1536+
strvec_pushl(&id.args, "/usr/bin/id", "-u", NULL);
1537+
if (capture_command(&id, &output, 0))
1538+
die(_("failed to discover user id"));
1539+
1540+
strbuf_trim_trailing_newline(&output);
1541+
return strbuf_detach(&output, NULL);
1542+
}
1543+
1544+
static int bootout(const char *filename)
1545+
{
1546+
int result;
1547+
struct strvec args = STRVEC_INIT;
1548+
char *uid = get_uid();
1549+
const char *launchctl = getenv("GIT_TEST_CRONTAB");
1550+
if (!launchctl)
1551+
launchctl = "/bin/launchctl";
1552+
1553+
strvec_split(&args, launchctl);
1554+
strvec_push(&args, "bootout");
1555+
strvec_pushf(&args, "gui/%s", uid);
1556+
strvec_push(&args, filename);
1557+
1558+
result = run_command_v_opt(args.v, 0);
1559+
1560+
strvec_clear(&args);
1561+
free(uid);
1562+
return result;
1563+
}
1564+
1565+
static int bootstrap(const char *filename)
1566+
{
1567+
int result;
1568+
struct strvec args = STRVEC_INIT;
1569+
char *uid = get_uid();
1570+
const char *launchctl = getenv("GIT_TEST_CRONTAB");
1571+
if (!launchctl)
1572+
launchctl = "/bin/launchctl";
1573+
1574+
strvec_split(&args, launchctl);
1575+
strvec_push(&args, "bootstrap");
1576+
strvec_pushf(&args, "gui/%s", uid);
1577+
strvec_push(&args, filename);
1578+
1579+
result = run_command_v_opt(args.v, 0);
1580+
1581+
strvec_clear(&args);
1582+
free(uid);
1583+
return result;
1584+
}
1585+
1586+
static int remove_plist(enum schedule_priority schedule)
1587+
{
1588+
const char *frequency = get_frequency(schedule);
1589+
char *name = get_service_name(frequency);
1590+
char *filename = get_service_filename(name);
1591+
int result = bootout(filename);
1592+
free(filename);
1593+
free(name);
1594+
return result;
1595+
}
1596+
1597+
static int remove_plists(void)
1598+
{
1599+
return remove_plist(SCHEDULE_HOURLY) ||
1600+
remove_plist(SCHEDULE_DAILY) ||
1601+
remove_plist(SCHEDULE_WEEKLY);
1602+
}
1603+
1604+
static int schedule_plist(const char *exec_path, enum schedule_priority schedule)
1605+
{
1606+
FILE *plist;
1607+
int i;
1608+
const char *preamble, *repeat;
1609+
const char *frequency = get_frequency(schedule);
1610+
char *name = get_service_name(frequency);
1611+
char *filename = get_service_filename(name);
1612+
1613+
if (safe_create_leading_directories(filename))
1614+
die(_("failed to create directories for '%s'"), filename);
1615+
plist = fopen(filename, "w");
1616+
1617+
if (!plist)
1618+
die(_("failed to open '%s'"), filename);
1619+
1620+
preamble = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
1621+
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
1622+
"<plist version=\"1.0\">"
1623+
"<dict>\n"
1624+
"<key>Label</key><string>%s</string>\n"
1625+
"<key>ProgramArguments</key>\n"
1626+
"<array>\n"
1627+
"<string>%s/git</string>\n"
1628+
"<string>--exec-path=%s</string>\n"
1629+
"<string>for-each-repo</string>\n"
1630+
"<string>--config=maintenance.repo</string>\n"
1631+
"<string>maintenance</string>\n"
1632+
"<string>run</string>\n"
1633+
"<string>--schedule=%s</string>\n"
1634+
"</array>\n"
1635+
"<key>StartCalendarInterval</key>\n"
1636+
"<array>\n";
1637+
fprintf(plist, preamble, name, exec_path, exec_path, frequency);
1638+
1639+
switch (schedule) {
1640+
case SCHEDULE_HOURLY:
1641+
repeat = "<dict>\n"
1642+
"<key>Hour</key><integer>%d</integer>\n"
1643+
"<key>Minute</key><integer>0</integer>\n"
1644+
"<dict>\n";
1645+
for (i = 1; i <= 23; i++)
1646+
fprintf(plist, repeat, i);
1647+
break;
1648+
1649+
case SCHEDULE_DAILY:
1650+
repeat = "<dict>\n"
1651+
"<key>Day</key><integer>%d</integer>\n"
1652+
"<key>Hour</key><integer>0</integer>\n"
1653+
"<key>Minute</key><integer>0</integer>\n"
1654+
"</dict>\n";
1655+
for (i = 1; i <= 6; i++)
1656+
fprintf(plist, repeat, i);
1657+
break;
1658+
1659+
case SCHEDULE_WEEKLY:
1660+
fprintf(plist,
1661+
"<dict>\n"
1662+
"<key>Day</key><integer>0</integer>\n"
1663+
"<key>Hour</key><integer>0</integer>\n"
1664+
"<key>Minute</key><integer>0</integer>\n"
1665+
"</dict>\n");
1666+
break;
1667+
1668+
default:
1669+
/* unreachable */
1670+
break;
1671+
}
1672+
fprintf(plist, "</array>\n</dict>\n</plist>\n");
1673+
1674+
/* bootout might fail if not already running, so ignore */
1675+
bootout(filename);
1676+
if (bootstrap(filename))
1677+
die(_("failed to bootstrap service %s"), filename);
1678+
1679+
fclose(plist);
1680+
free(filename);
1681+
free(name);
1682+
return 0;
1683+
}
1684+
1685+
static int add_plists(void)
1686+
{
1687+
const char *exec_path = git_exec_path();
1688+
1689+
return schedule_plist(exec_path, SCHEDULE_HOURLY) ||
1690+
schedule_plist(exec_path, SCHEDULE_DAILY) ||
1691+
schedule_plist(exec_path, SCHEDULE_WEEKLY);
1692+
}
1693+
1694+
static int platform_update_schedule(int run_maintenance, int fd)
1695+
{
1696+
if (run_maintenance)
1697+
return add_plists();
1698+
else
1699+
return remove_plists();
1700+
}
1701+
#else
14941702
#define BEGIN_LINE "# BEGIN GIT MAINTENANCE SCHEDULE"
14951703
#define END_LINE "# END GIT MAINTENANCE SCHEDULE"
14961704

@@ -1585,6 +1793,7 @@ static int platform_update_schedule(int run_maintenance, int fd)
15851793
fclose(cron_list);
15861794
return result;
15871795
}
1796+
#endif
15881797

15891798
static int update_background_schedule(int run_maintenance)
15901799
{

t/t7900-maintenance.sh

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ test_expect_success 'register and unregister' '
367367
test_cmp before actual
368368
'
369369

370-
test_expect_success 'start from empty cron table' '
370+
test_expect_success !MACOS_MAINTENANCE 'start from empty cron table' '
371371
GIT_TEST_CRONTAB="test-tool crontab cron.txt" git maintenance start &&
372372
373373
# start registers the repo
@@ -378,7 +378,7 @@ test_expect_success 'start from empty cron table' '
378378
grep "for-each-repo --config=maintenance.repo maintenance run --schedule=weekly" cron.txt
379379
'
380380

381-
test_expect_success 'stop from existing schedule' '
381+
test_expect_success !MACOS_MAINTENANCE 'stop from existing schedule' '
382382
GIT_TEST_CRONTAB="test-tool crontab cron.txt" git maintenance stop &&
383383
384384
# stop does not unregister the repo
@@ -389,12 +389,58 @@ test_expect_success 'stop from existing schedule' '
389389
test_must_be_empty cron.txt
390390
'
391391

392-
test_expect_success 'start preserves existing schedule' '
392+
test_expect_success !MACOS_MAINTENANCE 'start preserves existing schedule' '
393393
echo "Important information!" >cron.txt &&
394394
GIT_TEST_CRONTAB="test-tool crontab cron.txt" git maintenance start &&
395395
grep "Important information!" cron.txt
396396
'
397397

398+
test_expect_success MACOS_MAINTENANCE 'start and stop macOS maintenance' '
399+
echo "#!/bin/sh\necho \$@ >>args" >print-args &&
400+
chmod a+x print-args &&
401+
402+
rm -f args &&
403+
GIT_TEST_CRONTAB="./print-args" git maintenance start &&
404+
405+
# start registers the repo
406+
git config --get --global maintenance.repo "$(pwd)" &&
407+
408+
# ~/Library/LaunchAgents
409+
ls "$HOME/Library/LaunchAgents" >actual &&
410+
cat >expect <<-\EOF &&
411+
org.git-scm.git.daily.plist
412+
org.git-scm.git.hourly.plist
413+
org.git-scm.git.weekly.plist
414+
EOF
415+
test_cmp expect actual &&
416+
417+
rm expect &&
418+
for frequency in hourly daily weekly
419+
do
420+
PLIST="$HOME/Library/LaunchAgents/org.git-scm.git.$frequency.plist" &&
421+
grep schedule=$frequency "$PLIST" &&
422+
echo "bootout gui/$UID $PLIST" >>expect &&
423+
echo "bootstrap gui/$UID $PLIST" >>expect || return 1
424+
done &&
425+
test_cmp expect args &&
426+
427+
rm -f args &&
428+
GIT_TEST_CRONTAB="./print-args" git maintenance stop &&
429+
430+
# stop does not unregister the repo
431+
git config --get --global maintenance.repo "$(pwd)" &&
432+
433+
# stop does not remove plist files, but boots them out
434+
rm expect &&
435+
for frequency in hourly daily weekly
436+
do
437+
PLIST="$HOME/Library/LaunchAgents/org.git-scm.git.$frequency.plist" &&
438+
grep schedule=$frequency "$PLIST" &&
439+
echo "bootout gui/$UID $PLIST" >expect || return 1
440+
done &&
441+
test_cmp expect args
442+
'
443+
398444
test_expect_success 'register preserves existing strategy' '
399445
git config maintenance.strategy none &&
400446
git maintenance register &&

t/test-lib.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,6 +1703,10 @@ test_lazy_prereq REBASE_P '
17031703
test -z "$GIT_TEST_SKIP_REBASE_P"
17041704
'
17051705

1706+
test_lazy_prereq MACOS_MAINTENANCE '
1707+
launchctl list
1708+
'
1709+
17061710
# Ensure that no test accidentally triggers a Git command
17071711
# that runs 'crontab', affecting a user's cron schedule.
17081712
# Tests that verify the cron integration must set this locally

0 commit comments

Comments
 (0)