Fossil SCM

Merge the new --dirsonly and --emptydirs and --allckouts options for the "fossil clean" command onto trunk.

drh 2013-09-30 14:45 trunk merge
Commit 238c8dafd070b99af3b16e9d3899aad92648447d
3 files changed +123 -56 +30 -3 +124 -7
+123 -56
--- src/checkin.c
+++ src/checkin.c
@@ -363,27 +363,28 @@
363363
}
364364
db_finalize(&q);
365365
}
366366
367367
/*
368
-** Create a TEMP table named SFILE and add all unmanaged files named on the command-line
369
-** to that table. If directories are named, then add all unmanaged files contained
370
-** underneath those directories. If there are no files or directories named on the
371
-** command-line, then add all unmanaged files anywhere in the checkout.
368
+** Create a TEMP table named SFILE and add all unmanaged files named on
369
+** the command-line to that table. If directories are named, then add
370
+** all unmanaged files contained underneath those directories. If there
371
+** are no files or directories named on the command-line, then add all
372
+** unmanaged files anywhere in the checkout.
372373
*/
373374
static void locate_unmanaged_files(
374
- int argc, /* Number of command-line arguments to examine */
375
- char **argv, /* values of command-line arguments */
376
- unsigned scanFlags, /* Zero or more SCAN_xxx flags */
377
- Glob *pIgnore1, /* Do not add files that match this GLOB */
378
- Glob *pIgnore2 /* Omit files matching this GLOB too */
375
+ int argc, /* Number of command-line arguments to examine */
376
+ char **argv, /* values of command-line arguments */
377
+ unsigned scanFlags, /* Zero or more SCAN_xxx flags */
378
+ Glob *pIgnore1, /* Do not add files that match this GLOB */
379
+ Glob *pIgnore2 /* Omit files matching this GLOB too */
379380
){
380
- Blob name; /* Name of a candidate file or directory */
381
- char *zName; /* Name of a candidate file or directory */
382
- int isDir; /* 1 for a directory, 0 if doesn't exist, 2 for anything else */
383
- int i; /* Loop counter */
384
- int nRoot; /* length of g.zLocalRoot */
381
+ Blob name; /* Name of a candidate file or directory */
382
+ char *zName; /* Name of a candidate file or directory */
383
+ int isDir; /* 1 for a directory, 0 if doesn't exist, 2 for anything else */
384
+ int i; /* Loop counter */
385
+ int nRoot; /* length of g.zLocalRoot */
385386
386387
db_multi_exec("CREATE TEMP TABLE sfile(x TEXT PRIMARY KEY %s)",
387388
filename_collation());
388389
nRoot = (int)strlen(g.zLocalRoot);
389390
if( argc==0 ){
@@ -506,12 +507,28 @@
506507
** Files and subdirectories whose names begin with "." are
507508
** normally kept. They are handled if the "--dotfiles" option
508509
** is used.
509510
**
510511
** Options:
512
+** --allckouts Check for empty directories within any checkouts
513
+** that may be nested within the current one. This
514
+** option should be used with great care because the
515
+** empty-dirs setting (and other applicable settings)
516
+** belonging to the other repositories, if any, will
517
+** not be checked.
511518
** --case-sensitive <BOOL> override case-sensitive setting
519
+** --dirsonly Only remove empty directories. No files will
520
+** be removed. Using this option will automatically
521
+** enable the --emptydirs option as well.
512522
** --dotfiles Include files beginning with a dot (".").
523
+** --emptydirs Remove any empty directories that are not
524
+** explicitly exempted via the empty-dirs setting
525
+** or another applicable setting or command line
526
+** argument. Matching files, if any, are removed
527
+** prior to checking for any empty directories;
528
+** therefore, directories that contain only files
529
+** that were removed will be removed as well.
513530
** -f|--force Remove files without prompting.
514531
** --clean <CSG> Never prompt for files matching this
515532
** comma separated list of glob patterns.
516533
** --ignore <CSG> Ignore files matching patterns from the
517534
** comma separated list of glob patterns.
@@ -522,25 +539,27 @@
522539
** -v|--verbose Show all files as they are removed.
523540
**
524541
** See also: addremove, extra, status
525542
*/
526543
void clean_cmd(void){
527
- int allFlag, dryRunFlag, verboseFlag;
544
+ int allFileFlag, allDirFlag, dryRunFlag, verboseFlag;
545
+ int emptyDirsFlag, dirsOnlyFlag;
528546
unsigned scanFlags = 0;
529547
const char *zIgnoreFlag, *zKeepFlag, *zCleanFlag;
530
- Blob repo;
531
- Stmt q;
532548
Glob *pIgnore, *pKeep, *pClean;
533549
int nRoot;
534550
535551
dryRunFlag = find_option("dry-run","n",0)!=0;
536552
if( !dryRunFlag ){
537553
dryRunFlag = find_option("test",0,0)!=0; /* deprecated */
538554
}
539
- allFlag = find_option("force","f",0)!=0;
555
+ allFileFlag = allDirFlag = find_option("force","f",0)!=0;
556
+ dirsOnlyFlag = find_option("dirsonly",0,0)!=0;
557
+ emptyDirsFlag = dirsOnlyFlag || find_option("emptydirs","d",0)!=0;
540558
if( find_option("dotfiles",0,0)!=0 ) scanFlags |= SCAN_ALL;
541559
if( find_option("temp",0,0)!=0 ) scanFlags |= SCAN_TEMP;
560
+ if( find_option("allckouts",0,0)!=0 ) scanFlags |= SCAN_NESTED;
542561
zIgnoreFlag = find_option("ignore",0,1);
543562
verboseFlag = find_option("verbose","v",0)!=0;
544563
zKeepFlag = find_option("keep",0,1);
545564
zCleanFlag = find_option("clean",0,1);
546565
capture_case_sensitive_option();
@@ -556,51 +575,99 @@
556575
}
557576
verify_all_options();
558577
pIgnore = glob_create(zIgnoreFlag);
559578
pKeep = glob_create(zKeepFlag);
560579
pClean = glob_create(zCleanFlag);
561
- locate_unmanaged_files(g.argc-2, g.argv+2, scanFlags, pIgnore, pKeep);
562
- glob_free(pKeep);
563
- glob_free(pIgnore);
564
- db_prepare(&q,
565
- "SELECT %Q || x FROM sfile"
566
- " WHERE x NOT IN (%s)"
567
- " ORDER BY 1",
568
- g.zLocalRoot, fossil_all_reserved_names(0)
569
- );
570
- if( file_tree_name(g.zRepositoryName, &repo, 0) ){
571
- db_multi_exec("DELETE FROM sfile WHERE x=%B", &repo);
572
- }
573
- db_multi_exec("DELETE FROM sfile WHERE x IN (SELECT pathname FROM vfile)");
574580
nRoot = (int)strlen(g.zLocalRoot);
575
- while( db_step(&q)==SQLITE_ROW ){
576
- const char *zName = db_column_text(&q, 0);
577
- if( !allFlag && !dryRunFlag && !glob_match(pClean, zName+nRoot) ){
578
- Blob ans;
579
- char cReply;
580
- char *prompt = mprintf("Remove unmanaged file \"%s\" (a=all/y/N)? ",
581
- zName+nRoot);
582
- blob_zero(&ans);
583
- prompt_user(prompt, &ans);
584
- cReply = blob_str(&ans)[0];
585
- if( cReply=='a' || cReply=='A' ){
586
- allFlag = 1;
587
- }else if( cReply!='y' && cReply!='Y' ){
588
- blob_reset(&ans);
589
- continue;
590
- }
591
- blob_reset(&ans);
592
- }
593
- if( verboseFlag || dryRunFlag ){
594
- fossil_print("Removed unmanaged file: %s\n", zName+nRoot);
595
- }
596
- if( !dryRunFlag ){
597
- file_delete(zName);
598
- }
581
+ if( !dirsOnlyFlag ){
582
+ Stmt q;
583
+ Blob repo;
584
+ locate_unmanaged_files(g.argc-2, g.argv+2, scanFlags, pIgnore, pKeep);
585
+ db_prepare(&q,
586
+ "SELECT %Q || x FROM sfile"
587
+ " WHERE x NOT IN (%s)"
588
+ " ORDER BY 1",
589
+ g.zLocalRoot, fossil_all_reserved_names(0)
590
+ );
591
+ if( file_tree_name(g.zRepositoryName, &repo, 0) ){
592
+ db_multi_exec("DELETE FROM sfile WHERE x=%B", &repo);
593
+ }
594
+ db_multi_exec("DELETE FROM sfile WHERE x IN (SELECT pathname FROM vfile)");
595
+ while( db_step(&q)==SQLITE_ROW ){
596
+ const char *zName = db_column_text(&q, 0);
597
+ if( !allFileFlag && !dryRunFlag && !glob_match(pClean, zName+nRoot) ){
598
+ Blob ans;
599
+ char cReply;
600
+ char *prompt = mprintf("Remove unmanaged file \"%s\" (a=all/y/N)? ",
601
+ zName+nRoot);
602
+ blob_zero(&ans);
603
+ prompt_user(prompt, &ans);
604
+ cReply = blob_str(&ans)[0];
605
+ if( cReply=='a' || cReply=='A' ){
606
+ allFileFlag = 1;
607
+ }else if( cReply!='y' && cReply!='Y' ){
608
+ blob_reset(&ans);
609
+ continue;
610
+ }
611
+ blob_reset(&ans);
612
+ }
613
+ if ( dryRunFlag || file_delete(zName)==0 ){
614
+ if( verboseFlag || dryRunFlag ){
615
+ fossil_print("Removed unmanaged file: %s\n", zName+nRoot);
616
+ }
617
+ }else if( verboseFlag ){
618
+ fossil_print("Could not remove file: %s\n", zName+nRoot);
619
+ }
620
+ }
621
+ db_finalize(&q);
622
+ }
623
+ if( emptyDirsFlag ){
624
+ Glob *pEmptyDirs = glob_create(db_get("empty-dirs", 0));
625
+ Stmt q;
626
+ Blob root;
627
+ blob_init(&root, g.zLocalRoot, nRoot - 1);
628
+ vfile_dir_scan(&root, blob_size(&root), scanFlags, pIgnore, pKeep,
629
+ pEmptyDirs);
630
+ blob_reset(&root);
631
+ db_prepare(&q,
632
+ "SELECT %Q || x FROM dscan_temp"
633
+ " WHERE x NOT IN (%s) AND y = 0"
634
+ " ORDER BY 1 DESC",
635
+ g.zLocalRoot, fossil_all_reserved_names(0)
636
+ );
637
+ while( db_step(&q)==SQLITE_ROW ){
638
+ const char *zName = db_column_text(&q, 0);
639
+ if( !allDirFlag && !dryRunFlag && !glob_match(pClean, zName+nRoot) ){
640
+ Blob ans;
641
+ char cReply;
642
+ char *prompt = mprintf("Remove empty directory \"%s\" (a=all/y/N)? ",
643
+ zName+nRoot);
644
+ blob_zero(&ans);
645
+ prompt_user(prompt, &ans);
646
+ cReply = blob_str(&ans)[0];
647
+ if( cReply=='a' || cReply=='A' ){
648
+ allDirFlag = 1;
649
+ }else if( cReply!='y' && cReply!='Y' ){
650
+ blob_reset(&ans);
651
+ continue;
652
+ }
653
+ blob_reset(&ans);
654
+ }
655
+ if ( dryRunFlag || file_rmdir(zName)==0 ){
656
+ if( verboseFlag || dryRunFlag ){
657
+ fossil_print("Removed unmanaged directory: %s\n", zName+nRoot);
658
+ }
659
+ }else if( verboseFlag ){
660
+ fossil_print("Could not remove directory: %s\n", zName+nRoot);
661
+ }
662
+ }
663
+ db_finalize(&q);
664
+ glob_free(pEmptyDirs);
599665
}
600666
glob_free(pClean);
601
- db_finalize(&q);
667
+ glob_free(pKeep);
668
+ glob_free(pIgnore);
602669
}
603670
604671
/*
605672
** Prompt the user for a check-in or stash comment (given in pPrompt),
606673
** gather the response, then return the response in pComment.
607674
--- src/checkin.c
+++ src/checkin.c
@@ -363,27 +363,28 @@
363 }
364 db_finalize(&q);
365 }
366
367 /*
368 ** Create a TEMP table named SFILE and add all unmanaged files named on the command-line
369 ** to that table. If directories are named, then add all unmanaged files contained
370 ** underneath those directories. If there are no files or directories named on the
371 ** command-line, then add all unmanaged files anywhere in the checkout.
 
372 */
373 static void locate_unmanaged_files(
374 int argc, /* Number of command-line arguments to examine */
375 char **argv, /* values of command-line arguments */
376 unsigned scanFlags, /* Zero or more SCAN_xxx flags */
377 Glob *pIgnore1, /* Do not add files that match this GLOB */
378 Glob *pIgnore2 /* Omit files matching this GLOB too */
379 ){
380 Blob name; /* Name of a candidate file or directory */
381 char *zName; /* Name of a candidate file or directory */
382 int isDir; /* 1 for a directory, 0 if doesn't exist, 2 for anything else */
383 int i; /* Loop counter */
384 int nRoot; /* length of g.zLocalRoot */
385
386 db_multi_exec("CREATE TEMP TABLE sfile(x TEXT PRIMARY KEY %s)",
387 filename_collation());
388 nRoot = (int)strlen(g.zLocalRoot);
389 if( argc==0 ){
@@ -506,12 +507,28 @@
506 ** Files and subdirectories whose names begin with "." are
507 ** normally kept. They are handled if the "--dotfiles" option
508 ** is used.
509 **
510 ** Options:
 
 
 
 
 
 
511 ** --case-sensitive <BOOL> override case-sensitive setting
 
 
 
512 ** --dotfiles Include files beginning with a dot (".").
 
 
 
 
 
 
 
513 ** -f|--force Remove files without prompting.
514 ** --clean <CSG> Never prompt for files matching this
515 ** comma separated list of glob patterns.
516 ** --ignore <CSG> Ignore files matching patterns from the
517 ** comma separated list of glob patterns.
@@ -522,25 +539,27 @@
522 ** -v|--verbose Show all files as they are removed.
523 **
524 ** See also: addremove, extra, status
525 */
526 void clean_cmd(void){
527 int allFlag, dryRunFlag, verboseFlag;
 
528 unsigned scanFlags = 0;
529 const char *zIgnoreFlag, *zKeepFlag, *zCleanFlag;
530 Blob repo;
531 Stmt q;
532 Glob *pIgnore, *pKeep, *pClean;
533 int nRoot;
534
535 dryRunFlag = find_option("dry-run","n",0)!=0;
536 if( !dryRunFlag ){
537 dryRunFlag = find_option("test",0,0)!=0; /* deprecated */
538 }
539 allFlag = find_option("force","f",0)!=0;
 
 
540 if( find_option("dotfiles",0,0)!=0 ) scanFlags |= SCAN_ALL;
541 if( find_option("temp",0,0)!=0 ) scanFlags |= SCAN_TEMP;
 
542 zIgnoreFlag = find_option("ignore",0,1);
543 verboseFlag = find_option("verbose","v",0)!=0;
544 zKeepFlag = find_option("keep",0,1);
545 zCleanFlag = find_option("clean",0,1);
546 capture_case_sensitive_option();
@@ -556,51 +575,99 @@
556 }
557 verify_all_options();
558 pIgnore = glob_create(zIgnoreFlag);
559 pKeep = glob_create(zKeepFlag);
560 pClean = glob_create(zCleanFlag);
561 locate_unmanaged_files(g.argc-2, g.argv+2, scanFlags, pIgnore, pKeep);
562 glob_free(pKeep);
563 glob_free(pIgnore);
564 db_prepare(&q,
565 "SELECT %Q || x FROM sfile"
566 " WHERE x NOT IN (%s)"
567 " ORDER BY 1",
568 g.zLocalRoot, fossil_all_reserved_names(0)
569 );
570 if( file_tree_name(g.zRepositoryName, &repo, 0) ){
571 db_multi_exec("DELETE FROM sfile WHERE x=%B", &repo);
572 }
573 db_multi_exec("DELETE FROM sfile WHERE x IN (SELECT pathname FROM vfile)");
574 nRoot = (int)strlen(g.zLocalRoot);
575 while( db_step(&q)==SQLITE_ROW ){
576 const char *zName = db_column_text(&q, 0);
577 if( !allFlag && !dryRunFlag && !glob_match(pClean, zName+nRoot) ){
578 Blob ans;
579 char cReply;
580 char *prompt = mprintf("Remove unmanaged file \"%s\" (a=all/y/N)? ",
581 zName+nRoot);
582 blob_zero(&ans);
583 prompt_user(prompt, &ans);
584 cReply = blob_str(&ans)[0];
585 if( cReply=='a' || cReply=='A' ){
586 allFlag = 1;
587 }else if( cReply!='y' && cReply!='Y' ){
588 blob_reset(&ans);
589 continue;
590 }
591 blob_reset(&ans);
592 }
593 if( verboseFlag || dryRunFlag ){
594 fossil_print("Removed unmanaged file: %s\n", zName+nRoot);
595 }
596 if( !dryRunFlag ){
597 file_delete(zName);
598 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599 }
600 glob_free(pClean);
601 db_finalize(&q);
 
602 }
603
604 /*
605 ** Prompt the user for a check-in or stash comment (given in pPrompt),
606 ** gather the response, then return the response in pComment.
607
--- src/checkin.c
+++ src/checkin.c
@@ -363,27 +363,28 @@
363 }
364 db_finalize(&q);
365 }
366
367 /*
368 ** Create a TEMP table named SFILE and add all unmanaged files named on
369 ** the command-line to that table. If directories are named, then add
370 ** all unmanaged files contained underneath those directories. If there
371 ** are no files or directories named on the command-line, then add all
372 ** unmanaged files anywhere in the checkout.
373 */
374 static void locate_unmanaged_files(
375 int argc, /* Number of command-line arguments to examine */
376 char **argv, /* values of command-line arguments */
377 unsigned scanFlags, /* Zero or more SCAN_xxx flags */
378 Glob *pIgnore1, /* Do not add files that match this GLOB */
379 Glob *pIgnore2 /* Omit files matching this GLOB too */
380 ){
381 Blob name; /* Name of a candidate file or directory */
382 char *zName; /* Name of a candidate file or directory */
383 int isDir; /* 1 for a directory, 0 if doesn't exist, 2 for anything else */
384 int i; /* Loop counter */
385 int nRoot; /* length of g.zLocalRoot */
386
387 db_multi_exec("CREATE TEMP TABLE sfile(x TEXT PRIMARY KEY %s)",
388 filename_collation());
389 nRoot = (int)strlen(g.zLocalRoot);
390 if( argc==0 ){
@@ -506,12 +507,28 @@
507 ** Files and subdirectories whose names begin with "." are
508 ** normally kept. They are handled if the "--dotfiles" option
509 ** is used.
510 **
511 ** Options:
512 ** --allckouts Check for empty directories within any checkouts
513 ** that may be nested within the current one. This
514 ** option should be used with great care because the
515 ** empty-dirs setting (and other applicable settings)
516 ** belonging to the other repositories, if any, will
517 ** not be checked.
518 ** --case-sensitive <BOOL> override case-sensitive setting
519 ** --dirsonly Only remove empty directories. No files will
520 ** be removed. Using this option will automatically
521 ** enable the --emptydirs option as well.
522 ** --dotfiles Include files beginning with a dot (".").
523 ** --emptydirs Remove any empty directories that are not
524 ** explicitly exempted via the empty-dirs setting
525 ** or another applicable setting or command line
526 ** argument. Matching files, if any, are removed
527 ** prior to checking for any empty directories;
528 ** therefore, directories that contain only files
529 ** that were removed will be removed as well.
530 ** -f|--force Remove files without prompting.
531 ** --clean <CSG> Never prompt for files matching this
532 ** comma separated list of glob patterns.
533 ** --ignore <CSG> Ignore files matching patterns from the
534 ** comma separated list of glob patterns.
@@ -522,25 +539,27 @@
539 ** -v|--verbose Show all files as they are removed.
540 **
541 ** See also: addremove, extra, status
542 */
543 void clean_cmd(void){
544 int allFileFlag, allDirFlag, dryRunFlag, verboseFlag;
545 int emptyDirsFlag, dirsOnlyFlag;
546 unsigned scanFlags = 0;
547 const char *zIgnoreFlag, *zKeepFlag, *zCleanFlag;
 
 
548 Glob *pIgnore, *pKeep, *pClean;
549 int nRoot;
550
551 dryRunFlag = find_option("dry-run","n",0)!=0;
552 if( !dryRunFlag ){
553 dryRunFlag = find_option("test",0,0)!=0; /* deprecated */
554 }
555 allFileFlag = allDirFlag = find_option("force","f",0)!=0;
556 dirsOnlyFlag = find_option("dirsonly",0,0)!=0;
557 emptyDirsFlag = dirsOnlyFlag || find_option("emptydirs","d",0)!=0;
558 if( find_option("dotfiles",0,0)!=0 ) scanFlags |= SCAN_ALL;
559 if( find_option("temp",0,0)!=0 ) scanFlags |= SCAN_TEMP;
560 if( find_option("allckouts",0,0)!=0 ) scanFlags |= SCAN_NESTED;
561 zIgnoreFlag = find_option("ignore",0,1);
562 verboseFlag = find_option("verbose","v",0)!=0;
563 zKeepFlag = find_option("keep",0,1);
564 zCleanFlag = find_option("clean",0,1);
565 capture_case_sensitive_option();
@@ -556,51 +575,99 @@
575 }
576 verify_all_options();
577 pIgnore = glob_create(zIgnoreFlag);
578 pKeep = glob_create(zKeepFlag);
579 pClean = glob_create(zCleanFlag);
 
 
 
 
 
 
 
 
 
 
 
 
 
580 nRoot = (int)strlen(g.zLocalRoot);
581 if( !dirsOnlyFlag ){
582 Stmt q;
583 Blob repo;
584 locate_unmanaged_files(g.argc-2, g.argv+2, scanFlags, pIgnore, pKeep);
585 db_prepare(&q,
586 "SELECT %Q || x FROM sfile"
587 " WHERE x NOT IN (%s)"
588 " ORDER BY 1",
589 g.zLocalRoot, fossil_all_reserved_names(0)
590 );
591 if( file_tree_name(g.zRepositoryName, &repo, 0) ){
592 db_multi_exec("DELETE FROM sfile WHERE x=%B", &repo);
593 }
594 db_multi_exec("DELETE FROM sfile WHERE x IN (SELECT pathname FROM vfile)");
595 while( db_step(&q)==SQLITE_ROW ){
596 const char *zName = db_column_text(&q, 0);
597 if( !allFileFlag && !dryRunFlag && !glob_match(pClean, zName+nRoot) ){
598 Blob ans;
599 char cReply;
600 char *prompt = mprintf("Remove unmanaged file \"%s\" (a=all/y/N)? ",
601 zName+nRoot);
602 blob_zero(&ans);
603 prompt_user(prompt, &ans);
604 cReply = blob_str(&ans)[0];
605 if( cReply=='a' || cReply=='A' ){
606 allFileFlag = 1;
607 }else if( cReply!='y' && cReply!='Y' ){
608 blob_reset(&ans);
609 continue;
610 }
611 blob_reset(&ans);
612 }
613 if ( dryRunFlag || file_delete(zName)==0 ){
614 if( verboseFlag || dryRunFlag ){
615 fossil_print("Removed unmanaged file: %s\n", zName+nRoot);
616 }
617 }else if( verboseFlag ){
618 fossil_print("Could not remove file: %s\n", zName+nRoot);
619 }
620 }
621 db_finalize(&q);
622 }
623 if( emptyDirsFlag ){
624 Glob *pEmptyDirs = glob_create(db_get("empty-dirs", 0));
625 Stmt q;
626 Blob root;
627 blob_init(&root, g.zLocalRoot, nRoot - 1);
628 vfile_dir_scan(&root, blob_size(&root), scanFlags, pIgnore, pKeep,
629 pEmptyDirs);
630 blob_reset(&root);
631 db_prepare(&q,
632 "SELECT %Q || x FROM dscan_temp"
633 " WHERE x NOT IN (%s) AND y = 0"
634 " ORDER BY 1 DESC",
635 g.zLocalRoot, fossil_all_reserved_names(0)
636 );
637 while( db_step(&q)==SQLITE_ROW ){
638 const char *zName = db_column_text(&q, 0);
639 if( !allDirFlag && !dryRunFlag && !glob_match(pClean, zName+nRoot) ){
640 Blob ans;
641 char cReply;
642 char *prompt = mprintf("Remove empty directory \"%s\" (a=all/y/N)? ",
643 zName+nRoot);
644 blob_zero(&ans);
645 prompt_user(prompt, &ans);
646 cReply = blob_str(&ans)[0];
647 if( cReply=='a' || cReply=='A' ){
648 allDirFlag = 1;
649 }else if( cReply!='y' && cReply!='Y' ){
650 blob_reset(&ans);
651 continue;
652 }
653 blob_reset(&ans);
654 }
655 if ( dryRunFlag || file_rmdir(zName)==0 ){
656 if( verboseFlag || dryRunFlag ){
657 fossil_print("Removed unmanaged directory: %s\n", zName+nRoot);
658 }
659 }else if( verboseFlag ){
660 fossil_print("Could not remove directory: %s\n", zName+nRoot);
661 }
662 }
663 db_finalize(&q);
664 glob_free(pEmptyDirs);
665 }
666 glob_free(pClean);
667 glob_free(pKeep);
668 glob_free(pIgnore);
669 }
670
671 /*
672 ** Prompt the user for a check-in or stash comment (given in pPrompt),
673 ** gather the response, then return the response in pComment.
674
+30 -3
--- src/file.c
+++ src/file.c
@@ -461,20 +461,24 @@
461461
fossil_print("Set mtime of \"%s\" to %s (%lld)\n", zFile, zDate, iMTime);
462462
}
463463
464464
/*
465465
** Delete a file.
466
+**
467
+** Returns zero upon success.
466468
*/
467
-void file_delete(const char *zFilename){
469
+int file_delete(const char *zFilename){
470
+ int rc;
468471
#ifdef _WIN32
469472
wchar_t *z = fossil_utf8_to_filename(zFilename);
470
- _wunlink(z);
473
+ rc = _wunlink(z);
471474
#else
472475
char *z = fossil_utf8_to_filename(zFilename);
473
- unlink(zFilename);
476
+ rc = unlink(zFilename);
474477
#endif
475478
fossil_filename_free(z);
479
+ return rc;
476480
}
477481
478482
/*
479483
** Create the directory named in the argument, if it does not already
480484
** exist. If forceFlag is 1, delete any prior non-directory object
@@ -493,10 +497,33 @@
493497
wchar_t *zMbcs = fossil_utf8_to_filename(zName);
494498
rc = _wmkdir(zMbcs);
495499
#else
496500
char *zMbcs = fossil_utf8_to_filename(zName);
497501
rc = mkdir(zName, 0755);
502
+#endif
503
+ fossil_filename_free(zMbcs);
504
+ return rc;
505
+ }
506
+ return 0;
507
+}
508
+
509
+/*
510
+** Removes the directory named in the argument, if it exists. The directory
511
+** must be empty and cannot be the current directory or the root directory.
512
+**
513
+** Returns zero upon success.
514
+*/
515
+int file_rmdir(const char *zName){
516
+ int rc = file_wd_isdir(zName);
517
+ if( rc==2 ) return 1; /* cannot remove normal file */
518
+ if( rc==1 ){
519
+#if defined(_WIN32)
520
+ wchar_t *zMbcs = fossil_utf8_to_filename(zName);
521
+ rc = _wrmdir(zMbcs);
522
+#else
523
+ char *zMbcs = fossil_utf8_to_filename(zName);
524
+ rc = rmdir(zName);
498525
#endif
499526
fossil_filename_free(zMbcs);
500527
return rc;
501528
}
502529
return 0;
503530
--- src/file.c
+++ src/file.c
@@ -461,20 +461,24 @@
461 fossil_print("Set mtime of \"%s\" to %s (%lld)\n", zFile, zDate, iMTime);
462 }
463
464 /*
465 ** Delete a file.
 
 
466 */
467 void file_delete(const char *zFilename){
 
468 #ifdef _WIN32
469 wchar_t *z = fossil_utf8_to_filename(zFilename);
470 _wunlink(z);
471 #else
472 char *z = fossil_utf8_to_filename(zFilename);
473 unlink(zFilename);
474 #endif
475 fossil_filename_free(z);
 
476 }
477
478 /*
479 ** Create the directory named in the argument, if it does not already
480 ** exist. If forceFlag is 1, delete any prior non-directory object
@@ -493,10 +497,33 @@
493 wchar_t *zMbcs = fossil_utf8_to_filename(zName);
494 rc = _wmkdir(zMbcs);
495 #else
496 char *zMbcs = fossil_utf8_to_filename(zName);
497 rc = mkdir(zName, 0755);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498 #endif
499 fossil_filename_free(zMbcs);
500 return rc;
501 }
502 return 0;
503
--- src/file.c
+++ src/file.c
@@ -461,20 +461,24 @@
461 fossil_print("Set mtime of \"%s\" to %s (%lld)\n", zFile, zDate, iMTime);
462 }
463
464 /*
465 ** Delete a file.
466 **
467 ** Returns zero upon success.
468 */
469 int file_delete(const char *zFilename){
470 int rc;
471 #ifdef _WIN32
472 wchar_t *z = fossil_utf8_to_filename(zFilename);
473 rc = _wunlink(z);
474 #else
475 char *z = fossil_utf8_to_filename(zFilename);
476 rc = unlink(zFilename);
477 #endif
478 fossil_filename_free(z);
479 return rc;
480 }
481
482 /*
483 ** Create the directory named in the argument, if it does not already
484 ** exist. If forceFlag is 1, delete any prior non-directory object
@@ -493,10 +497,33 @@
497 wchar_t *zMbcs = fossil_utf8_to_filename(zName);
498 rc = _wmkdir(zMbcs);
499 #else
500 char *zMbcs = fossil_utf8_to_filename(zName);
501 rc = mkdir(zName, 0755);
502 #endif
503 fossil_filename_free(zMbcs);
504 return rc;
505 }
506 return 0;
507 }
508
509 /*
510 ** Removes the directory named in the argument, if it exists. The directory
511 ** must be empty and cannot be the current directory or the root directory.
512 **
513 ** Returns zero upon success.
514 */
515 int file_rmdir(const char *zName){
516 int rc = file_wd_isdir(zName);
517 if( rc==2 ) return 1; /* cannot remove normal file */
518 if( rc==1 ){
519 #if defined(_WIN32)
520 wchar_t *zMbcs = fossil_utf8_to_filename(zName);
521 rc = _wrmdir(zMbcs);
522 #else
523 char *zMbcs = fossil_utf8_to_filename(zName);
524 rc = rmdir(zName);
525 #endif
526 fossil_filename_free(zMbcs);
527 return rc;
528 }
529 return 0;
530
+124 -7
--- src/vfile.c
+++ src/vfile.c
@@ -418,10 +418,11 @@
418418
/*
419419
** Values for the scanFlags parameter to vfile_scan().
420420
*/
421421
#define SCAN_ALL 0x001 /* Includes files that begin with "." */
422422
#define SCAN_TEMP 0x002 /* Only Fossil-generated files like *-baseline */
423
+#define SCAN_NESTED 0x004 /* Scan for empty dirs in nested checkouts */
423424
#endif /* INTERFACE */
424425
425426
/*
426427
** Load into table SFILE the name of every ordinary file in
427428
** the directory pPath. Omit the first nPrefix characters of
@@ -428,15 +429,16 @@
428429
** of pPath when inserting into the SFILE table.
429430
**
430431
** Subdirectories are scanned recursively.
431432
** Omit files named in VFILE.
432433
**
433
-** Files whose names begin with "." are omitted unless allFlag is true.
434
+** Files whose names begin with "." are omitted unless the SCAN_ALL
435
+** flag is set.
434436
**
435
-** Any files or directories that match the glob pattern pIgnore are
436
-** excluded from the scan. Name matching occurs after the first
437
-** nPrefix characters are elided from the filename.
437
+** Any files or directories that match the glob patterns pIgnore*
438
+** are excluded from the scan. Name matching occurs after the
439
+** first nPrefix characters are elided from the filename.
438440
*/
439441
void vfile_scan(
440442
Blob *pPath, /* Directory to be scanned */
441443
int nPrefix, /* Number of bytes in directory name */
442444
unsigned scanFlags, /* Zero or more SCAN_xxx flags */
@@ -443,11 +445,10 @@
443445
Glob *pIgnore1, /* Do not add files that match this GLOB */
444446
Glob *pIgnore2 /* Omit files matching this GLOB too */
445447
){
446448
DIR *d;
447449
int origSize;
448
- const char *zDir;
449450
struct dirent *pEntry;
450451
int skipAll = 0;
451452
static Stmt ins;
452453
static int depth = 0;
453454
void *zNative;
@@ -468,12 +469,11 @@
468469
" pathname=:file %s)", filename_collation()
469470
);
470471
}
471472
depth++;
472473
473
- zDir = blob_str(pPath);
474
- zNative = fossil_utf8_to_filename(zDir);
474
+ zNative = fossil_utf8_to_filename(blob_str(pPath));
475475
d = opendir(zNative);
476476
if( d ){
477477
while( (pEntry=readdir(d))!=0 ){
478478
char *zPath;
479479
char *zUtf8;
@@ -509,10 +509,127 @@
509509
depth--;
510510
if( depth==0 ){
511511
db_finalize(&ins);
512512
}
513513
}
514
+
515
+/*
516
+** Scans the specified base directory for any directories within it, while
517
+** keeping a count of how many files they each contains, either directly or
518
+** indirectly.
519
+**
520
+** Subdirectories are scanned recursively.
521
+** Omit files named in VFILE.
522
+**
523
+** Directories whose names begin with "." are omitted unless the SCAN_ALL
524
+** flag is set.
525
+**
526
+** Any directories that match the glob patterns pIgnore* are excluded from
527
+** the scan. Name matching occurs after the first nPrefix characters are
528
+** elided from the filename.
529
+**
530
+** Returns the total number of files found.
531
+*/
532
+int vfile_dir_scan(
533
+ Blob *pPath, /* Base directory to be scanned */
534
+ int nPrefix, /* Number of bytes in base directory name */
535
+ unsigned scanFlags, /* Zero or more SCAN_xxx flags */
536
+ Glob *pIgnore1, /* Do not add directories that match this GLOB */
537
+ Glob *pIgnore2, /* Omit directories matching this GLOB too */
538
+ Glob *pIgnore3 /* Omit directories matching this GLOB too */
539
+){
540
+ int result = 0;
541
+ DIR *d;
542
+ int origSize;
543
+ struct dirent *pEntry;
544
+ int skipAll = 0;
545
+ static Stmt ins;
546
+ static Stmt upd;
547
+ static int depth = 0;
548
+ void *zNative;
549
+
550
+ origSize = blob_size(pPath);
551
+ if( pIgnore1 || pIgnore2 || pIgnore3 ){
552
+ blob_appendf(pPath, "/");
553
+ if( glob_match(pIgnore1, &blob_str(pPath)[nPrefix+1]) ) skipAll = 1;
554
+ if( glob_match(pIgnore2, &blob_str(pPath)[nPrefix+1]) ) skipAll = 1;
555
+ if( glob_match(pIgnore3, &blob_str(pPath)[nPrefix+1]) ) skipAll = 1;
556
+ blob_resize(pPath, origSize);
557
+ }
558
+ if( skipAll ) return result;
559
+
560
+ if( depth==0 ){
561
+ db_multi_exec("DROP TABLE IF EXISTS dscan_temp;"
562
+ "CREATE TEMP TABLE dscan_temp("
563
+ " x TEXT PRIMARY KEY %s, y INTEGER)",
564
+ filename_collation());
565
+ db_prepare(&ins,
566
+ "INSERT OR IGNORE INTO dscan_temp(x, y) SELECT :file, :count"
567
+ " WHERE NOT EXISTS(SELECT 1 FROM vfile WHERE"
568
+ " pathname GLOB :file || '/*' %s)", filename_collation()
569
+ );
570
+ db_prepare(&upd,
571
+ "UPDATE OR IGNORE dscan_temp SET y = coalesce(y, 0) + 1"
572
+ " WHERE x=:file %s",
573
+ filename_collation()
574
+ );
575
+ }
576
+ depth++;
577
+
578
+ zNative = fossil_utf8_to_filename(blob_str(pPath));
579
+ d = opendir(zNative);
580
+ if( d ){
581
+ while( (pEntry=readdir(d))!=0 ){
582
+ char *zOrigPath;
583
+ char *zPath;
584
+ char *zUtf8;
585
+ if( pEntry->d_name[0]=='.' ){
586
+ if( (scanFlags & SCAN_ALL)==0 ) continue;
587
+ if( pEntry->d_name[1]==0 ) continue;
588
+ if( pEntry->d_name[1]=='.' && pEntry->d_name[2]==0 ) continue;
589
+ }
590
+ zOrigPath = mprintf("%s", blob_str(pPath));
591
+ zUtf8 = fossil_filename_to_utf8(pEntry->d_name);
592
+ blob_appendf(pPath, "/%s", zUtf8);
593
+ zPath = blob_str(pPath);
594
+ if( glob_match(pIgnore1, &zPath[nPrefix+1]) ||
595
+ glob_match(pIgnore2, &zPath[nPrefix+1]) ||
596
+ glob_match(pIgnore3, &zPath[nPrefix+1]) ){
597
+ /* do nothing */
598
+ }else if( file_wd_isdir(zPath)==1 ){
599
+ if( (scanFlags & SCAN_NESTED) || !vfile_top_of_checkout(zPath) ){
600
+ char *zSavePath = mprintf("%s", zPath);
601
+ int count = vfile_dir_scan(pPath, nPrefix, scanFlags, pIgnore1,
602
+ pIgnore2, pIgnore3);
603
+ db_bind_text(&ins, ":file", &zSavePath[nPrefix+1]);
604
+ db_bind_int(&ins, ":count", count);
605
+ db_step(&ins);
606
+ db_reset(&ins);
607
+ fossil_free(zSavePath);
608
+ result += count; /* found X normal files? */
609
+ }
610
+ }else if( file_wd_isfile_or_link(zPath) ){
611
+ db_bind_text(&upd, ":file", zOrigPath);
612
+ db_step(&upd);
613
+ db_reset(&upd);
614
+ result++; /* found 1 normal file */
615
+ }
616
+ fossil_filename_free(zUtf8);
617
+ blob_resize(pPath, origSize);
618
+ fossil_free(zOrigPath);
619
+ }
620
+ closedir(d);
621
+ }
622
+ fossil_filename_free(zNative);
623
+
624
+ depth--;
625
+ if( depth==0 ){
626
+ db_finalize(&upd);
627
+ db_finalize(&ins);
628
+ }
629
+ return result;
630
+}
514631
515632
/*
516633
** Compute an aggregate MD5 checksum over the disk image of every
517634
** file in vid. The file names are part of the checksum. The resulting
518635
** checksum is the same as is expected on the R-card of a manifest.
519636
--- src/vfile.c
+++ src/vfile.c
@@ -418,10 +418,11 @@
418 /*
419 ** Values for the scanFlags parameter to vfile_scan().
420 */
421 #define SCAN_ALL 0x001 /* Includes files that begin with "." */
422 #define SCAN_TEMP 0x002 /* Only Fossil-generated files like *-baseline */
 
423 #endif /* INTERFACE */
424
425 /*
426 ** Load into table SFILE the name of every ordinary file in
427 ** the directory pPath. Omit the first nPrefix characters of
@@ -428,15 +429,16 @@
428 ** of pPath when inserting into the SFILE table.
429 **
430 ** Subdirectories are scanned recursively.
431 ** Omit files named in VFILE.
432 **
433 ** Files whose names begin with "." are omitted unless allFlag is true.
 
434 **
435 ** Any files or directories that match the glob pattern pIgnore are
436 ** excluded from the scan. Name matching occurs after the first
437 ** nPrefix characters are elided from the filename.
438 */
439 void vfile_scan(
440 Blob *pPath, /* Directory to be scanned */
441 int nPrefix, /* Number of bytes in directory name */
442 unsigned scanFlags, /* Zero or more SCAN_xxx flags */
@@ -443,11 +445,10 @@
443 Glob *pIgnore1, /* Do not add files that match this GLOB */
444 Glob *pIgnore2 /* Omit files matching this GLOB too */
445 ){
446 DIR *d;
447 int origSize;
448 const char *zDir;
449 struct dirent *pEntry;
450 int skipAll = 0;
451 static Stmt ins;
452 static int depth = 0;
453 void *zNative;
@@ -468,12 +469,11 @@
468 " pathname=:file %s)", filename_collation()
469 );
470 }
471 depth++;
472
473 zDir = blob_str(pPath);
474 zNative = fossil_utf8_to_filename(zDir);
475 d = opendir(zNative);
476 if( d ){
477 while( (pEntry=readdir(d))!=0 ){
478 char *zPath;
479 char *zUtf8;
@@ -509,10 +509,127 @@
509 depth--;
510 if( depth==0 ){
511 db_finalize(&ins);
512 }
513 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
515 /*
516 ** Compute an aggregate MD5 checksum over the disk image of every
517 ** file in vid. The file names are part of the checksum. The resulting
518 ** checksum is the same as is expected on the R-card of a manifest.
519
--- src/vfile.c
+++ src/vfile.c
@@ -418,10 +418,11 @@
418 /*
419 ** Values for the scanFlags parameter to vfile_scan().
420 */
421 #define SCAN_ALL 0x001 /* Includes files that begin with "." */
422 #define SCAN_TEMP 0x002 /* Only Fossil-generated files like *-baseline */
423 #define SCAN_NESTED 0x004 /* Scan for empty dirs in nested checkouts */
424 #endif /* INTERFACE */
425
426 /*
427 ** Load into table SFILE the name of every ordinary file in
428 ** the directory pPath. Omit the first nPrefix characters of
@@ -428,15 +429,16 @@
429 ** of pPath when inserting into the SFILE table.
430 **
431 ** Subdirectories are scanned recursively.
432 ** Omit files named in VFILE.
433 **
434 ** Files whose names begin with "." are omitted unless the SCAN_ALL
435 ** flag is set.
436 **
437 ** Any files or directories that match the glob patterns pIgnore*
438 ** are excluded from the scan. Name matching occurs after the
439 ** first nPrefix characters are elided from the filename.
440 */
441 void vfile_scan(
442 Blob *pPath, /* Directory to be scanned */
443 int nPrefix, /* Number of bytes in directory name */
444 unsigned scanFlags, /* Zero or more SCAN_xxx flags */
@@ -443,11 +445,10 @@
445 Glob *pIgnore1, /* Do not add files that match this GLOB */
446 Glob *pIgnore2 /* Omit files matching this GLOB too */
447 ){
448 DIR *d;
449 int origSize;
 
450 struct dirent *pEntry;
451 int skipAll = 0;
452 static Stmt ins;
453 static int depth = 0;
454 void *zNative;
@@ -468,12 +469,11 @@
469 " pathname=:file %s)", filename_collation()
470 );
471 }
472 depth++;
473
474 zNative = fossil_utf8_to_filename(blob_str(pPath));
 
475 d = opendir(zNative);
476 if( d ){
477 while( (pEntry=readdir(d))!=0 ){
478 char *zPath;
479 char *zUtf8;
@@ -509,10 +509,127 @@
509 depth--;
510 if( depth==0 ){
511 db_finalize(&ins);
512 }
513 }
514
515 /*
516 ** Scans the specified base directory for any directories within it, while
517 ** keeping a count of how many files they each contains, either directly or
518 ** indirectly.
519 **
520 ** Subdirectories are scanned recursively.
521 ** Omit files named in VFILE.
522 **
523 ** Directories whose names begin with "." are omitted unless the SCAN_ALL
524 ** flag is set.
525 **
526 ** Any directories that match the glob patterns pIgnore* are excluded from
527 ** the scan. Name matching occurs after the first nPrefix characters are
528 ** elided from the filename.
529 **
530 ** Returns the total number of files found.
531 */
532 int vfile_dir_scan(
533 Blob *pPath, /* Base directory to be scanned */
534 int nPrefix, /* Number of bytes in base directory name */
535 unsigned scanFlags, /* Zero or more SCAN_xxx flags */
536 Glob *pIgnore1, /* Do not add directories that match this GLOB */
537 Glob *pIgnore2, /* Omit directories matching this GLOB too */
538 Glob *pIgnore3 /* Omit directories matching this GLOB too */
539 ){
540 int result = 0;
541 DIR *d;
542 int origSize;
543 struct dirent *pEntry;
544 int skipAll = 0;
545 static Stmt ins;
546 static Stmt upd;
547 static int depth = 0;
548 void *zNative;
549
550 origSize = blob_size(pPath);
551 if( pIgnore1 || pIgnore2 || pIgnore3 ){
552 blob_appendf(pPath, "/");
553 if( glob_match(pIgnore1, &blob_str(pPath)[nPrefix+1]) ) skipAll = 1;
554 if( glob_match(pIgnore2, &blob_str(pPath)[nPrefix+1]) ) skipAll = 1;
555 if( glob_match(pIgnore3, &blob_str(pPath)[nPrefix+1]) ) skipAll = 1;
556 blob_resize(pPath, origSize);
557 }
558 if( skipAll ) return result;
559
560 if( depth==0 ){
561 db_multi_exec("DROP TABLE IF EXISTS dscan_temp;"
562 "CREATE TEMP TABLE dscan_temp("
563 " x TEXT PRIMARY KEY %s, y INTEGER)",
564 filename_collation());
565 db_prepare(&ins,
566 "INSERT OR IGNORE INTO dscan_temp(x, y) SELECT :file, :count"
567 " WHERE NOT EXISTS(SELECT 1 FROM vfile WHERE"
568 " pathname GLOB :file || '/*' %s)", filename_collation()
569 );
570 db_prepare(&upd,
571 "UPDATE OR IGNORE dscan_temp SET y = coalesce(y, 0) + 1"
572 " WHERE x=:file %s",
573 filename_collation()
574 );
575 }
576 depth++;
577
578 zNative = fossil_utf8_to_filename(blob_str(pPath));
579 d = opendir(zNative);
580 if( d ){
581 while( (pEntry=readdir(d))!=0 ){
582 char *zOrigPath;
583 char *zPath;
584 char *zUtf8;
585 if( pEntry->d_name[0]=='.' ){
586 if( (scanFlags & SCAN_ALL)==0 ) continue;
587 if( pEntry->d_name[1]==0 ) continue;
588 if( pEntry->d_name[1]=='.' && pEntry->d_name[2]==0 ) continue;
589 }
590 zOrigPath = mprintf("%s", blob_str(pPath));
591 zUtf8 = fossil_filename_to_utf8(pEntry->d_name);
592 blob_appendf(pPath, "/%s", zUtf8);
593 zPath = blob_str(pPath);
594 if( glob_match(pIgnore1, &zPath[nPrefix+1]) ||
595 glob_match(pIgnore2, &zPath[nPrefix+1]) ||
596 glob_match(pIgnore3, &zPath[nPrefix+1]) ){
597 /* do nothing */
598 }else if( file_wd_isdir(zPath)==1 ){
599 if( (scanFlags & SCAN_NESTED) || !vfile_top_of_checkout(zPath) ){
600 char *zSavePath = mprintf("%s", zPath);
601 int count = vfile_dir_scan(pPath, nPrefix, scanFlags, pIgnore1,
602 pIgnore2, pIgnore3);
603 db_bind_text(&ins, ":file", &zSavePath[nPrefix+1]);
604 db_bind_int(&ins, ":count", count);
605 db_step(&ins);
606 db_reset(&ins);
607 fossil_free(zSavePath);
608 result += count; /* found X normal files? */
609 }
610 }else if( file_wd_isfile_or_link(zPath) ){
611 db_bind_text(&upd, ":file", zOrigPath);
612 db_step(&upd);
613 db_reset(&upd);
614 result++; /* found 1 normal file */
615 }
616 fossil_filename_free(zUtf8);
617 blob_resize(pPath, origSize);
618 fossil_free(zOrigPath);
619 }
620 closedir(d);
621 }
622 fossil_filename_free(zNative);
623
624 depth--;
625 if( depth==0 ){
626 db_finalize(&upd);
627 db_finalize(&ins);
628 }
629 return result;
630 }
631
632 /*
633 ** Compute an aggregate MD5 checksum over the disk image of every
634 ** file in vid. The file names are part of the checksum. The resulting
635 ** checksum is the same as is expected on the R-card of a manifest.
636

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button