prune: strategies for linked checkouts

(alias R=$GIT_COMMON_DIR/worktrees/<id>) - linked checkouts are supposed to keep its location in $R/gitdir up to date. The use case is auto fixup after a manual checkout move. - linked checkouts are supposed to update mtime of $R/gitdir. If $R/gitdir's mtime is older than a limit, and it points to nowhere, worktrees/<id> is to be pruned. - If $R/locked exists, worktrees/<id> is not supposed to be pruned. If $R/locked exists and $R/gitdir's mtime is older than a really long limit, warn about old unused repo. - "git checkout --to" is supposed to make a hard link named $R/link pointing to the .git file on supported file systems to help detect the user manually deleting the checkout. If $R/link exists and its link count is greated than 1, the repo is kept. Helped-by: Marc Branchaud <marcnarc@xiplink.com> Helped-by: Eric Sunshine <sunshine@sunshineco.com> Helped-by: Johannes Sixt <j6t@kdbg.org> Signed-off-by: Marc Branchaud <marcnarc@xiplink.com> Signed-off-by: Nguyễn Thái Ngọc Duy <pclouds@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Nguyễn Thái Ngọc Duy committed Nov 30, 2014 at 15:24 UTC 23af91d102e1efaff33b77ab7746356835a3d600
7 files changed +251 -2
Documentation/git-checkout.txt
+20
@@ -434,6 +434,26 @@ thumb is do not make any assumption about whether a path belongs to
434 $GIT_DIR or $GIT_COMMON_DIR when you need to directly access something
435 inside $GIT_DIR. Use `git rev-parse --git-path` to get the final path.
436
437 +When you are done with a linked working tree you can simply delete it.
438 +You can clean up any stale $GIT_DIR/worktrees entries via `git prune
439 +--worktrees` in the main or any linked working tree.
440 +
441 +If you move a linked working directory to another file system, or
442 +within a file system that does not support hard links, you need to run
443 +at least one git command inside the linked working directory
444 +(e.g. `git status`) in order to update its entry in $GIT_DIR/worktrees
445 +so that it does not get automatically removed.
446 +
447 +To prevent `git prune --worktrees` from deleting a $GIT_DIR/worktrees
448 +entry (which can be useful in some situations, such as when the
449 +entry's working tree is stored on a portable device), add a file named
450 +'locked' to the entry's directory. The file contains the reason in
451 +plain text. For example, if a linked working tree's `.git` file points
452 +to `/path/main/.git/worktrees/test-next` then a file named
453 +`/path/main/.git/worktrees/test-next/locked` will prevent the
454 +`test-next` entry from being pruned. See
455 +linkgit:gitrepository-layout[5] for details.
456 +
457 EXAMPLES
458 --------
459
Documentation/git-prune.txt
+3
@@ -48,6 +48,9 @@ OPTIONS
48 --expire <time>::
49 Only expire loose objects older than <time>.
50
51 +--worktrees::
52 + Prune dead working tree information in $GIT_DIR/worktrees.
53 +
54 <head>...::
55 In addition to objects
56 reachable from any of our references, keep objects
Documentation/gitrepository-layout.txt
+19
@@ -259,6 +259,25 @@ worktrees::
259 $GIT_COMMON_DIR is set and "$GIT_COMMON_DIR/worktrees" will be
260 used instead.
261
262 +worktrees/<id>/gitdir::
263 + A text file containing the absolute path back to the .git file
264 + that points to here. This is used to check if the linked
265 + repository has been manually removed and there is no need to
266 + keep this directory any more. mtime of this file should be
267 + updated every time the linked repository is accessed.
268 +
269 +worktrees/<id>/locked::
270 + If this file exists, the linked repository may be on a
271 + portable device and not available. It does not mean that the
272 + linked repository is gone and `worktrees/<id>` could be
273 + removed. The file's content contains a reason string on why
274 + the repository is locked.
275 +
276 +worktrees/<id>/link::
277 + If this file exists, it is a hard link to the linked .git
278 + file. It is used to detect if the linked repository is
279 + manually removed.
280 +
281 SEE ALSO
282 --------
283 linkgit:git-init[1],
builtin/checkout.c
+17 -2
@@ -826,7 +826,7 @@ static int prepare_linked_checkout(const struct checkout_opts *opts,
826 const char *path = opts->new_worktree, *name;
827 struct stat st;
828 struct child_process cp;
829 - int counter = 0, len;
829 + int counter = 0, len, ret;
830
831 if (!new->commit)
832 die(_("no branch specified"));
@@ -857,11 +857,21 @@ static int prepare_linked_checkout(const struct checkout_opts *opts,
857 if (mkdir(sb_repo.buf, 0777))
858 die_errno(_("could not create directory of '%s'"), sb_repo.buf);
859
860 + /*
861 + * lock the incomplete repo so prune won't delete it, unlock
862 + * after the preparation is over.
863 + */
864 + strbuf_addf(&sb, "%s/locked", sb_repo.buf);
865 + write_file(sb.buf, 1, "initializing\n");
866 +
867 strbuf_addf(&sb_git, "%s/.git", path);
868 if (safe_create_leading_directories_const(sb_git.buf))
869 die_errno(_("could not create leading directories of '%s'"),
870 sb_git.buf);
871
872 + strbuf_reset(&sb);
873 + strbuf_addf(&sb, "%s/gitdir", sb_repo.buf);
874 + write_file(sb.buf, 1, "%s\n", real_path(sb_git.buf));
875 write_file(sb_git.buf, 1, "gitdir: %s/worktrees/%s\n",
876 real_path(get_git_common_dir()), name);
877 /*
@@ -870,6 +880,7 @@ static int prepare_linked_checkout(const struct checkout_opts *opts,
880 * value would do because this value will be ignored and
881 * replaced at the next (real) checkout.
882 */
883 + strbuf_reset(&sb);
884 strbuf_addf(&sb, "%s/HEAD", sb_repo.buf);
885 write_file(sb.buf, 1, "%s\n", sha1_to_hex(new->commit->object.sha1));
886 strbuf_reset(&sb);
@@ -885,7 +896,11 @@ static int prepare_linked_checkout(const struct checkout_opts *opts,
896 memset(&cp, 0, sizeof(cp));
897 cp.git_cmd = 1;
898 cp.argv = opts->saved_argv;
888 - return run_command(&cp);
899 + ret = run_command(&cp);
900 + strbuf_reset(&sb);
901 + strbuf_addf(&sb, "%s/locked", sb_repo.buf);
902 + unlink_or_warn(sb.buf);
903 + return ret;
904 }
905
906 static int git_checkout_config(const char *var, const char *value, void *cb)
builtin/prune.c
+95
@@ -76,6 +76,91 @@ static int prune_subdir(int nr, const char *path, void *data)
76 return 0;
77 }
78
79 +static int prune_worktree(const char *id, struct strbuf *reason)
80 +{
81 + struct stat st;
82 + char *path;
83 + int fd, len;
84 +
85 + if (!is_directory(git_path("worktrees/%s", id))) {
86 + strbuf_addf(reason, _("Removing worktrees/%s: not a valid directory"), id);
87 + return 1;
88 + }
89 + if (file_exists(git_path("worktrees/%s/locked", id)))
90 + return 0;
91 + if (stat(git_path("worktrees/%s/gitdir", id), &st)) {
92 + strbuf_addf(reason, _("Removing worktrees/%s: gitdir file does not exist"), id);
93 + return 1;
94 + }
95 + fd = open(git_path("worktrees/%s/gitdir", id), O_RDONLY);
96 + if (fd < 0) {
97 + strbuf_addf(reason, _("Removing worktrees/%s: unable to read gitdir file (%s)"),
98 + id, strerror(errno));
99 + return 1;
100 + }
101 + len = st.st_size;
102 + path = xmalloc(len + 1);
103 + read_in_full(fd, path, len);
104 + close(fd);
105 + while (len && (path[len - 1] == '\n' || path[len - 1] == '\r'))
106 + len--;
107 + if (!len) {
108 + strbuf_addf(reason, _("Removing worktrees/%s: invalid gitdir file"), id);
109 + free(path);
110 + return 1;
111 + }
112 + path[len] = '\0';
113 + if (!file_exists(path)) {
114 + struct stat st_link;
115 + free(path);
116 + /*
117 + * the repo is moved manually and has not been
118 + * accessed since?
119 + */
120 + if (!stat(git_path("worktrees/%s/link", id), &st_link) &&
121 + st_link.st_nlink > 1)
122 + return 0;
123 + strbuf_addf(reason, _("Removing worktrees/%s: gitdir file points to non-existent location"), id);
124 + return 1;
125 + }
126 + free(path);
127 + return st.st_mtime <= expire;
128 +}
129 +
130 +static void prune_worktrees(void)
131 +{
132 + struct strbuf reason = STRBUF_INIT;
133 + struct strbuf path = STRBUF_INIT;
134 + DIR *dir = opendir(git_path("worktrees"));
135 + struct dirent *d;
136 + int ret;
137 + if (!dir)
138 + return;
139 + while ((d = readdir(dir)) != NULL) {
140 + if (!strcmp(d->d_name, ".") || !strcmp(d->d_name, ".."))
141 + continue;
142 + strbuf_reset(&reason);
143 + if (!prune_worktree(d->d_name, &reason))
144 + continue;
145 + if (show_only || verbose)
146 + printf("%s\n", reason.buf);
147 + if (show_only)
148 + continue;
149 + strbuf_reset(&path);
150 + strbuf_addstr(&path, git_path("worktrees/%s", d->d_name));
151 + ret = remove_dir_recursively(&path, 0);
152 + if (ret < 0 && errno == ENOTDIR)
153 + ret = unlink(path.buf);
154 + if (ret)
155 + error(_("failed to remove: %s"), strerror(errno));
156 + }
157 + closedir(dir);
158 + if (!show_only)
159 + rmdir(git_path("worktrees"));
160 + strbuf_release(&reason);
161 + strbuf_release(&path);
162 +}
163 +
164 /*
165 * Write errors (particularly out of space) can result in
166 * failed temporary packs (and more rarely indexes and other
@@ -102,10 +187,12 @@ int cmd_prune(int argc, const char **argv, const char *prefix)
187 {
188 struct rev_info revs;
189 struct progress *progress = NULL;
190 + int do_prune_worktrees = 0;
191 const struct option options[] = {
192 OPT__DRY_RUN(&show_only, N_("do not remove, show only")),
193 OPT__VERBOSE(&verbose, N_("report pruned objects")),
194 OPT_BOOL(0, "progress", &show_progress, N_("show progress")),
195 + OPT_BOOL(0, "worktrees", &do_prune_worktrees, N_("prune .git/worktrees")),
196 OPT_EXPIRY_DATE(0, "expire", &expire,
197 N_("expire objects older than <time>")),
198 OPT_END()
@@ -118,6 +205,14 @@ int cmd_prune(int argc, const char **argv, const char *prefix)
205 init_revisions(&revs, prefix);
206
207 argc = parse_options(argc, argv, prefix, options, prune_usage, 0);
208 +
209 + if (do_prune_worktrees) {
210 + if (argc)
211 + die(_("--worktrees does not take extra arguments"));
212 + prune_worktrees();
213 + return 0;
214 + }
215 +
216 while (argc--) {
217 unsigned char sha1[20];
218 const char *name = *argv++;
setup.c
+13
@@ -390,6 +390,17 @@ static int check_repository_format_gently(const char *gitdir, int *nongit_ok)
390 return ret;
391 }
392
393 +static void update_linked_gitdir(const char *gitfile, const char *gitdir)
394 +{
395 + struct strbuf path = STRBUF_INIT;
396 + struct stat st;
397 +
398 + strbuf_addf(&path, "%s/gitfile", gitdir);
399 + if (stat(path.buf, &st) || st.st_mtime + 24 * 3600 < time(NULL))
400 + write_file(path.buf, 0, "%s\n", gitfile);
401 + strbuf_release(&path);
402 +}
403 +
404 /*
405 * Try to read the location of the git directory from the .git file,
406 * return path to git directory if found.
@@ -438,6 +449,8 @@ const char *read_gitfile(const char *path)
449
450 if (!is_git_directory(dir))
451 die("Not a git repository: %s", dir);
452 +
453 + update_linked_gitdir(path, dir);
454 path = real_path(dir);
455
456 free(buf);
t/t2026-prune-linked-checkouts.sh new
+84
@@ -0,0 +1,84 @@
1 +#!/bin/sh
2 +
3 +test_description='prune $GIT_DIR/worktrees'
4 +
5 +. ./test-lib.sh
6 +
7 +test_expect_success 'prune --worktrees on normal repo' '
8 + git prune --worktrees &&
9 + test_must_fail git prune --worktrees abc
10 +'
11 +
12 +test_expect_success 'prune files inside $GIT_DIR/worktrees' '
13 + mkdir .git/worktrees &&
14 + : >.git/worktrees/abc &&
15 + git prune --worktrees --verbose >actual &&
16 + cat >expect <<EOF &&
17 +Removing worktrees/abc: not a valid directory
18 +EOF
19 + test_i18ncmp expect actual &&
20 + ! test -f .git/worktrees/abc &&
21 + ! test -d .git/worktrees
22 +'
23 +
24 +test_expect_success 'prune directories without gitdir' '
25 + mkdir -p .git/worktrees/def/abc &&
26 + : >.git/worktrees/def/def &&
27 + cat >expect <<EOF &&
28 +Removing worktrees/def: gitdir file does not exist
29 +EOF
30 + git prune --worktrees --verbose >actual &&
31 + test_i18ncmp expect actual &&
32 + ! test -d .git/worktrees/def &&
33 + ! test -d .git/worktrees
34 +'
35 +
36 +test_expect_success POSIXPERM 'prune directories with unreadable gitdir' '
37 + mkdir -p .git/worktrees/def/abc &&
38 + : >.git/worktrees/def/def &&
39 + : >.git/worktrees/def/gitdir &&
40 + chmod u-r .git/worktrees/def/gitdir &&
41 + git prune --worktrees --verbose >actual &&
42 + test_i18ngrep "Removing worktrees/def: unable to read gitdir file" actual &&
43 + ! test -d .git/worktrees/def &&
44 + ! test -d .git/worktrees
45 +'
46 +
47 +test_expect_success 'prune directories with invalid gitdir' '
48 + mkdir -p .git/worktrees/def/abc &&
49 + : >.git/worktrees/def/def &&
50 + : >.git/worktrees/def/gitdir &&
51 + git prune --worktrees --verbose >actual &&
52 + test_i18ngrep "Removing worktrees/def: invalid gitdir file" actual &&
53 + ! test -d .git/worktrees/def &&
54 + ! test -d .git/worktrees
55 +'
56 +
57 +test_expect_success 'prune directories with gitdir pointing to nowhere' '
58 + mkdir -p .git/worktrees/def/abc &&
59 + : >.git/worktrees/def/def &&
60 + echo "$(pwd)"/nowhere >.git/worktrees/def/gitdir &&
61 + git prune --worktrees --verbose >actual &&
62 + test_i18ngrep "Removing worktrees/def: gitdir file points to non-existent location" actual &&
63 + ! test -d .git/worktrees/def &&
64 + ! test -d .git/worktrees
65 +'
66 +
67 +test_expect_success 'not prune locked checkout' '
68 + test_when_finished rm -r .git/worktrees
69 + mkdir -p .git/worktrees/ghi &&
70 + : >.git/worktrees/ghi/locked &&
71 + git prune --worktrees &&
72 + test -d .git/worktrees/ghi
73 +'
74 +
75 +test_expect_success 'not prune recent checkouts' '
76 + test_when_finished rm -r .git/worktrees
77 + mkdir zz &&
78 + mkdir -p .git/worktrees/jlm &&
79 + echo "$(pwd)"/zz >.git/worktrees/jlm/gitdir &&
80 + git prune --worktrees --verbose --expire=2.days.ago &&
81 + test -d .git/worktrees/jlm
82 +'
83 +
84 +test_done